# Version Story API documentation

---

> Version Story compares two versions of a Word or PDF document and produces an exact redline lawyers can rely on. Available in Claude and ChatGPT, and as a REST API. Every page of the REST API documentation, in reading order. The first is the prompt a coding agent follows to integrate the API.

---

<!-- https://www.versionstory.com/developers/quickstart -->

# Version Story API: a working redline in your app

## Instructions for the coding agent

You are adding Version Story's document comparison API to this codebase. Version Story compares two
versions of a legal document (Word or PDF) and returns a redline: the later version with every
change marked. Each format has one job, and this prompt uses each only for its job:

- **PDF:** what a lawyer sees. Always the first view.
- **HTML:** the same redline as an interactive view to embed in an app (step 6).
- **Word:** a document with real tracked changes, for the lawyer to accept, reject and send on.
- **Markdown:** the redline as text, for a model to read.
- **JSON:** the changes as structured data, for code to act on. Never draw a redline from it: it
  describes the document's structure and changes, not its layout in pages, and the PDF and the HTML
  already show the redline exactly.

**Why the PDF comes first.** Explain this to the engineer when you propose the PDF:

- **A redline is a deliverable, not a diff.** Engineers new to legal tend to see a redline as a diff.
  Lawyers know it is a deliverable their clients judge them by.
- **It is the record.** Lawyers make the record. A litigator asks that the record reflect what
  happened; a transactional lawyer builds the record of a deal as it goes: what was sent, when, and
  what changed. A PDF redline is that record at one moment, the changes between two drafts exactly
  as they stood, and it reads the same in five years. A view an app draws for itself is not a
  record: it changes whenever the code does.
- **It looks right everywhere.** The same pages in the app, in an agent's chat, in Slack, attached to
  an email, in a client portal, and on paper. There is no viewer to build.
- **It is what lawyers review, and what clients see.** Clean pages with page numbers to cite, and a
  polished deliverable that carries the firm's name.

**Your first deliverable:** at the place where lawyers should see what changed between two versions
of a document, this codebase calls the Version Story API and shows the PDF redline. That place is
one of two kinds (step 3 says how to tell):

- **A screen in an app:** the PDF appears on that screen.
- **An AI agent the lawyers work with** (a chat assistant, an agent harness, an MCP server of the
  team's own): the comparison becomes a tool the agent calls, and the PDF appears where the harness
  shows files to the lawyer, such as an artifact or side panel, or an attachment in the chat.

Build that, and only that, first. When it works, show the engineer and offer the enhancements in
step 6.

**Done means:** in a running copy of the system (local if possible, development otherwise; see
Where to run it), the engineer opens the screen or asks the agent for a redline, a live API call
compares two test documents, and the PDF redline appears there.

### Where to run it

- Try to get the app running locally first. Follow its README, setup scripts and Docker or compose
  files, and ask the engineer for anything you cannot find: credentials, seed data, access to
  internal services.
- If the app cannot run locally, because it depends on services, data or access that exist only in a
  shared environment, build and test in the team's development or staging environment instead,
  through the team's usual deploy process. Ask the engineer before you deploy anything, and tell
  them which environment you are using.
- Never production.
- Steps 1 and 2 need none of this: run them from any shell on the engineer's machine.

### Rules

- Work on a new branch: `git checkout -b version-story-poc`.
- The API key is a secret.
  - Store it the way this codebase already stores secrets (a cloud secrets manager, Vault,
    Doppler, Kubernetes secrets, or an untracked `.env`), under the name `VERSION_STORY_API_KEY`.
  - Never commit it, log it, print it in full, or send it to the browser. Call the API from server
    code only.
- Use the public test documents below until the engineer confirms their security team has cleared
  real client documents.
- Follow this codebase's conventions: its HTTP client, its job or task pattern for slow work, its
  error handling, its PDF viewer if it has one. Read before you write.
- Do not change behavior anywhere but the integration point. The existing flow keeps working if the
  API is unreachable.

### 1. Get an API key

First look for a Version Story key the team already has, without printing any you find:

- The environment, and every place this codebase keeps secrets (see Rules): `.env` files and
  their examples, the secrets manager or vault it uses, CI and deployment configuration. Search
  for `VERSION_STORY` and for values starting `vs_live_`.
- Ask the engineer whether they have one, too.

Check a key you found with `GET https://api.versionstory.com/v1/reference/errors`: `200` means the
key is valid, so store it as `VERSION_STORY_API_KEY` if it is not already and go to step 2; `401`
means it was revoked, so get a new one below.

Otherwise get the key yourself, through the engineer's browser. Their only steps are to sign in to
Version Story (or register) and click **Allow**; you create the key and store it.

- Tell the engineer a browser tab is about to open, and what to do in it.
- Save the script below outside the repository and run it with `python3`, passing your own name
  and the machine's, such as `"Claude Code on <hostname>"`; it becomes the key's name in the
  team's Version Story account. It opens the browser, waits for Allow, creates the key, sends the
  browser to Version Story's "You're signed in" page, and prints JSON whose `api_key` is the new
  key. Store `api_key` as `VERSION_STORY_API_KEY` in the secrets store you chose (see Rules)
  without printing it, then delete the script.
- Where the engineer's browser cannot reach this machine (SSH, a container, a cloud session), put
  the URL the script prints in your message to the engineer, in full: they may not see your tool
  output. After Allow their browser lands on a `127.0.0.1` address that fails to load; ask them to
  paste that address to you, and fetch it from this machine with curl, which finishes the script.

```python
import base64, hashlib, http.server, json, platform, secrets, sys, urllib.error, urllib.parse, urllib.request, webbrowser

APP = "https://app.versionstory.com"
API = "https://api.versionstory.com"
CLIENT_NAME = sys.argv[1] if len(sys.argv) > 1 else f"Coding agent on {platform.node()}"

verifier = secrets.token_urlsafe(64)
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
state = secrets.token_urlsafe(16)


def redeem(answer):
    """The new key's JSON for the code the browser brought, or an error to report."""
    if answer.get("state") != state:
        return None, "the browser returned a different state"
    if "code" not in answer:
        return None, answer.get("error", "the browser returned no code")
    request = urllib.request.Request(
        f"{API}/v1/api-keys",
        data=json.dumps({"code": answer["code"], "code_verifier": verifier, "redirect_uri": redirect_uri}).encode(),
        headers={"Content-Type": "application/json"},
    )
    try:
        with urllib.request.urlopen(request, timeout=30) as response:
            return response.read().decode(), None
    except urllib.error.HTTPError as error:
        return None, error.read().decode()


class Callback(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        parts = urllib.parse.urlsplit(self.path)
        if parts.path != "/callback":
            self.send_response(404)
            self.end_headers()
            return
        answer = dict(urllib.parse.parse_qsl(parts.query))
        self.server.key, self.server.error = redeem(answer)
        result = "granted" if self.server.key else "denied" if answer.get("error") == "access_denied" else "failed"
        self.send_response(302)
        self.send_header("Location", f"{APP}/developer/authorize/complete?result={result}")
        self.end_headers()

    def log_message(self, *args):
        pass


class Listener(http.server.HTTPServer):
    def handle_timeout(self):
        self.timed_out = "nobody clicked Allow within 15 minutes; run the script again"


server = Listener(("127.0.0.1", 0), Callback)
server.key = server.error = server.timed_out = None
server.timeout = 15 * 60  # stop waiting for Allow after 15 minutes
redirect_uri = f"http://127.0.0.1:{server.server_address[1]}/callback"
url = f"{APP}/developer/authorize?" + urllib.parse.urlencode(
    {
        "client_name": CLIENT_NAME,
        "redirect_uri": redirect_uri,
        "code_challenge": challenge,
        "code_challenge_method": "S256",
        "state": state,
    }
)
print(f"Sign in to Version Story and click Allow: {url}", file=sys.stderr, flush=True)
webbrowser.open(url)
while server.key is None and server.error is None:
    server.handle_request()
    server.error = server.error or (None if server.key else server.timed_out)
if server.error:
    sys.exit(f"Sign-in did not finish: {server.error}")
print(server.key)
```

If the browser shows "Developer access needed", the engineer's organization has not given them
developer access yet: ask an owner of their organization, or Version Story at help@versionstory.com,
to turn it on, then run the script again.

### 2. Run a test comparison

Run this before step 3, however you got the key: the check in step 1 proves only the key, and this
proves the comparison and the download too. Put the key in this shell's environment as
`VERSION_STORY_API_KEY`, read from where you stored it, without printing it. Then download a test
pair. The documents are public, so they need no key:

```bash
BASE=https://version-story-evals.s3.us-west-2.amazonaws.com/files/FILE-0189--nda-demo
curl -sO $BASE/v1.docx && curl -sO $BASE/v2.docx
```

Start a comparison. It answers at once with an id:

```bash
curl -s https://api.versionstory.com/v1/compare \
  -H "Authorization: Bearer $VERSION_STORY_API_KEY" \
  -F original=@v1.docx -F modified=@v2.docx -F author="Test"
# {"comparison_id":"cmp_…","status":"processing"}
```

Get the redline. Always name `format=pdf`: a call that names no format gets the Word file, which a
browser cannot show. The call answers at once; while `status` is `processing`, wait the seconds its
`Retry-After` header gives (one) and call again with the same arguments:

```bash
curl -s "https://api.versionstory.com/v1/compare/cmp_…?format=pdf" \
  -H "Authorization: Bearer $VERSION_STORY_API_KEY"
# {"comparison_id":"cmp_…","status":"ready","file":{"file_id":"…","status":"ready",
#   "downloads":[{"format":"pdf","url":"https://documents.versionstory.com/…","file_name":"Redline - v1 to v2.pdf","expires_at":"…"}]}}
```

Download `file.downloads[0].url` and open the PDF. If this works, the key and the API are fine, and any
later problem is in the integration.

### 3. Find where the redline belongs

First tell which kind of system this is. If lawyers work through an AI agent here (the codebase
defines tools for a model, an MCP server, or a chat or agent loop that calls an LLM), the
integration point is a new tool that agent calls when a lawyer asks what changed. Otherwise it is a
screen. If both exist, ask the engineer which one lawyers use for this work.

Then find the place where a lawyer would want to see what changed between two versions of a
document. Good signs:

- A screen, agent step or tool that holds two versions of the same document: a draft and the
  counterparty's return, a template and a filled-in agreement, an original and an agent's edited
  version.
- A call to another comparison product or library. Version Story replaces that call.
  - Commercial: Litera, Workshare, Draftable, Word's Compare.
  - Open source: Docxodus, Open-XML-PowerTools' WmlComparer, python-redlines, or a text diff
    library such as diff-match-patch, jsdiff or Python's difflib.
- An upload or email-ingest path where a new version of an existing document arrives.

Tell the engineer what you found, with file paths, and confirm the place before you build.

### 4. Build it

1. **Configuration.** Read `VERSION_STORY_API_KEY` from the secrets store, and the base URL
   `https://api.versionstory.com` from configuration.
2. **A small server-side client** with one function, roughly
   `compare(original, modified, author) -> PDF bytes`:
   - `POST /v1/compare` as `multipart/form-data` with the two files as `original` (the earlier
     version) and `modified` (the later one). `author` is optional: the name the tracked changes
     are attributed to.
   - Then `GET /v1/compare/{comparison_id}?format=pdf` until `status` is `ready`, waiting the
     `Retry-After` seconds between calls, with a timeout of a few minutes overall. A typical pair is
     ready within ten seconds.
   - Download the PDF from `file.downloads[].url` on the server. Links expire after one hour, so
     never store a link: store the PDF, or store the `comparison_id` and ask for a fresh link.
   - Errors arrive as `{"error": {"code": …, "message": …, "request_id": …}}`. Log the `code` and
     `request_id` (we can trace any request by its id), and surface a plain message to the user.
     `status: failed` is final. `GET /v1/reference/errors` explains every code.
3. **Run it where the work runs.** If this app has a background-job pattern, use it: a comparison
   of long documents can take more than a minute.
4. **Show the PDF at the integration point.**
   - **A screen:** with the app's existing PDF viewer; if there is none, use the simplest one that
     fits the stack (an `<iframe>` of a same-origin endpoint that streams the stored PDF, or
     pdf.js). Show a loading state while the comparison runs.
   - **An agent tool:** define the tool the way this codebase defines its other tools, taking the
     two documents in the form the harness already passes files to tools, and the author. Deliver
     the PDF to the lawyer the way the harness already shows files: an artifact or side panel, an
     attachment in the chat, or a download link served by this app. If the harness has no way to
     show a file yet, ask the engineer which to add. Return a short text result to the model (that
     the redline is ready, and its file name), never the PDF's bytes, which the model cannot read.
5. **A test** that runs the client against a recorded response, in this codebase's test style.

Word, PDF and legacy `.doc` files compare with each other in any combination, so the two versions
may arrive in different formats.

### 5. Show it working

In the local copy of the system, or in development if it could not run locally, compare a test
pair, the one most like this team's documents (see Test documents): open the screen, or ask the
agent for a redline of the pair. Ask the engineer to look at the redline where it appeared. Then
commit on the branch and summarize what you changed, file by file.

### 6. Offer the next steps

Ask the engineer which of these to build next. Each is a small change to the client from step 4:
ask for more formats in the same `GET`, for example `?format=pdf,html,docx`.

1. **A redline your agents can read.** `format=md` is the later version as Markdown, one paragraph
   per line, with every change inline: `<ins>`, `<del>`, and `<moved-from>` and `<moved-to>` for
   moved text. A comment at the top explains the tags, and a list after the document gives every
   change with its author and date. Give it to an agent as context when it summarizes changes,
   checks them against a playbook, or answers "what changed in Section 7?" For an agent tool, this
   is the text to return to the model with the PDF.
2. **Download buttons.** `format=docx` is a Word document with real tracked changes, which a lawyer
   can accept, reject and send on. `format=pdf_changed_pages_only` is a PDF of the changed pages
   only, for a quick review of a long agreement. Add the buttons beside the PDF, or for an agent
   tool, deliver them as more files the same way as the PDF.
3. **An interactive redline in the app.** Keep the PDF as the default view, since it is exactly what
   the lawyer would print or send. Add an interactive view beside it with the HTML redline, the same
   redline laid out in pages with its own Revisions panel: change cards, filters by type, and
   Previous and Next. Read https://www.versionstory.com/developers/rest/embed before building it.
   - **On the server**, call `GET /v1/compare/{comparison_id}?format=html` with the key, and return
     the signed link in `file.downloads[].url` to the page. Never send the key to the browser, and
     never store the link: it expires after an hour, so fetch a fresh one on view.
   - **On the page**, frame it: `<iframe sandbox="allow-scripts" src="{url}#embed_origin=<this app's
     origin>">`. The options go in the fragment, never the query, which would break the link's
     signature. Never add `allow-same-origin`, and never put the HTML into the app's own DOM.
   - **Driving it from the app** (optional): the page can move to the next change or to a paragraph
     and hear where the lawyer is, over `postMessage` with `vs:*` messages; the embed page lists them
     and how to turn off parts of the Revisions panel the app draws itself.
   - Do not build a viewer from `format=json` instead. The JSON describes the document's structure
     and changes, not its layout in pages, so a viewer drawn from it re-implements Word's layout and
     still shows less than the HTML does.
4. **Structured data for your app's logic.** `format=json` lets deterministic code read the
   changes without parsing a document: count insertions and deletions, list the paragraphs that
   changed, flag a change in a section your app cares about, or link a change to your own records
   (its paragraph `id` is what the HTML redline scrolls to). It is for what the code decides, never
   for what the lawyer sees.

If this organization has zero-day retention turned on, call `POST /v1/compare/{comparison_id}/acknowledge`
once every format you need is stored: Version Story then deletes the documents and the redline
immediately.

---

## Test documents

Each set is a pair of versions of one agreement, `v1.docx` (the original) and `v2.docx` (the later
version), public so anyone can download them:

| Set | What it is |
|---|---|
| `FILE-0189--nda-demo` | A four-page NDA, then the version with the parties, date and signature blocks filled in, a paragraph moved, and word-level edits throughout. Start here. |
| `FILE-0190--consulting-demo` | A consulting agreement, then the consultant's heavy mark-up: parties, services, payment, IP ownership, confidentiality and term. |
| `FILE-0191--table-demo` | A licence agreement's special-terms table as a blank template, then filled in: rows renumbered and removed, and long clauses inside the table's cells. |

- One file: `https://version-story-evals.s3.us-west-2.amazonaws.com/files/<set>/v1.docx`
- Every set, as JSON (each file's URL and sha256):
  https://version-story-evals.s3.us-west-2.amazonaws.com/index.json

## API reference

| Call | What it does |
|---|---|
| `POST /v1/api-keys` | Redeems the browser sign-in's code for a new key (step 1); the one call that takes no key. |
| `POST /v1/compare` | Starts a comparison of `original` and `modified`; returns `comparison_id`. |
| `GET /v1/compare/{comparison_id}?format=…` | The comparison's status and, once ready, download links in `file.downloads`: `pdf` (to show; always name it, since the default is `docx`), `html` (the interactive redline to frame in a sandboxed `iframe`, step 6), `docx` (to edit in Word), `md` (for a model), `json` (for code, never for display). `pdf_changed_pages_only` is in the top-level `downloads`. |
| `POST /v1/compare/{comparison_id}/acknowledge` | With zero-day retention, deletes the content once you have saved it. |
| `GET /v1/reference/{topic}` | Reference topics: `errors`, `redline` (the change markup). |

- Every request sends `Authorization: Bearer <VERSION_STORY_API_KEY>`.
- The full OpenAPI description, which needs no key: `https://api.versionstory.com/openapi.json`.
- Documentation: https://www.versionstory.com/developers/rest/compare, and every page as Markdown
  from https://www.versionstory.com/llms.txt.

## Help

If the API does not behave as this file describes, stop and tell the engineer, with the error's
`code` and `request_id`. Version Story's team will help directly at help@versionstory.com.

---

<!-- https://www.versionstory.com/developers/authentication -->

# Authentication

> Every request carries an API key. Create one on the Developer page, or let a coding agent get one with a click from you.

> Integrating Version Story into a codebase as a coding agent? Follow https://www.versionstory.com/developers/quickstart.md: it signs the engineer in, gets an API key, and puts the PDF redline in their app first.

Every request carries an API key as a bearer token in the `Authorization` header. The key is a
machine credential: it belongs to your organization, and no person signs in to use it.

## API keys

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

### Getting a key

Keys are created in the Version Story web app, on the **Developer** page (the terminal icon
in the sidebar, or [app.versionstory.com/developer](https://app.versionstory.com/developer)),
under **API keys**. Issuing or revoking one requires the **Developer** permission on the
organization.

- **Signing up gives you access.** [Create an account](https://app.versionstory.com/register)
  and you own a new organization with the Developer permission, so the page is there
  straight away. New organizations start on an [API trial](/developers/reference/formats-and-limits#api-trials).
- **In an existing organization,** a member who manages members can grant you the
  Developer permission from your entry in the organization's member settings.

![The API keys tab of Version Story's Developer page, listing each key with its last-used and created dates](https://www.versionstory.com/developers/authentication/1-api-keys-card.0039572e.jpg)

The Developer page's other tabs are useful alongside the keys: **Overview** charts API
activity and success rate and holds the [zero-day retention](/developers/rest/compare#zero-day-retention)
setting, and **Repository** shows every document and redline the API has created.

Give the key a name that says what it serves — "Contract intake integration" reads better
than "key 2" when someone audits the list a year later. The token is shown once, on
creation, in a banner with a copy button. Store it before you dismiss the banner.

### Getting a key from a coding agent

A coding agent setting up an integration (Claude Code, Codex, Cursor and the like) can get its
own key, with one click from you and no copying. It opens Version Story in your browser, you
sign in or register and click **Allow**, and the agent receives a new key and stores it where
your secrets live. [Quickstart with a coding agent](/developers/quickstart#with-a-coding-agent) is a prompt that has
an agent do it, with the script it runs. Under the hood it is the OAuth authorization code flow with
PKCE, for a native app on a loopback address ([RFC 8252](https://datatracker.ietf.org/doc/html/rfc8252)):

1. The agent makes a PKCE pair (a random `code_verifier` and its S256 `code_challenge`) and a
   random `state`, and listens for one request at `http://127.0.0.1:<port>/callback`.
2. It opens `https://app.versionstory.com/developer/authorize` with the query `client_name`,
   which becomes the key's name, `redirect_uri`, `code_challenge`, `code_challenge_method=S256`
   and `state`.
3. You sign in or register, and allow the request for your organization. Allowing it needs the
   Developer permission, as creating a key on the Developer page does.
4. The browser brings a one-time `code` back to the agent's listener, and the agent redeems it
   within five minutes:

```bash
curl -sS https://api.versionstory.com/v1/api-keys \
  -H "Content-Type: application/json" \
  -d '{"code": "...", "code_verifier": "...", "redirect_uri": "http://127.0.0.1:53682/callback"}'
```

```json
{
  "api_key": "vs_live_...",
  "name": "Claude Code on dana-laptop",
  "organization_id": "...",
  "organization_name": "Reyes LLP"
}
```

`POST /v1/api-keys` is the one call that takes no key. The `redirect_uri` must be exactly
`http://127.0.0.1:<port>/callback` (or `[::1]`), with no query, and the same value the authorize
page was given; a code is redeemed once. A code that is unknown, expired, already used, or
sent with the wrong verifier or redirect is `400 INVALID_AUTHORIZATION_CODE`.

The person who allowed the request is emailed that a key was created with their account, and
the key list marks the key **Created by sign-in**, so a key allowed by mistake is easy to spot
and revoke.

### What a key is

A key looks like `vs_live_{id}_{secret}`, for example `vs_live_9f3c…_R7x…`, and works with
`https://api.versionstory.com`.

A key belongs to the **organization**, not to the person who created it, so it keeps
working when that person changes roles or leaves. Requests authenticate as the
organization's service account: a machine identity that exists only to carry API work and
has no sign-in of its own. That account is what every API-created comparison is attributed
to and metered against. The member who issued the key is recorded as `created_by_email`, as
an audit trail and nothing more.

### Handling a key

- **The secret is shown once, at minting, and is never recoverable.** Version Story stores
  only a SHA-256 hash of it. If you lose the key, you get a new one; nobody can read the
  old one back to you.
- Treat it like a password. Keep it in a secrets manager or an environment variable, never
  in source control, and never in client-side code — a key in a browser bundle is a key
  the whole internet has.
- An organization can hold several keys at once, and each one can be revoked on its own.
  Revocation takes effect on the next request. Rotate in this order: create the new key,
  deploy it, then revoke the old one. The other order leaves a window with no valid
  credential.
- The key list shows each key's name, when it was created, and when it was last used, and
  marks revoked keys — enough to tell a live integration from one you can safely retire.
  Hover a key's Created date to see who issued it.

### Auth failures

| Status | Code | Meaning |
| --- | --- | --- |
| 400 | `INVALID_AUTHORIZATION_CODE` | `POST /v1/api-keys` only: the code is unknown, expired or used, or its verifier or redirect does not match |
| 401 | `INVALID_API_KEY` | Missing, malformed, unknown, revoked, or expired key |
| 403 | `API_KEY_ORG_MISMATCH` | The key's service account is no longer operating in the key's organization |

`API_KEY_ORG_MISMATCH` is deliberately loud rather than silent: honoring a request whose
principal has drifted would attribute and bill the work to the wrong organization. Because
a key's service account is derived from the organization itself, you should never see this
in practice. If you do, issue a new key and revoke the old one.

---

<!-- https://www.versionstory.com/developers/rest/compare -->

# Compare

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

> Integrating Version Story into a codebase as a coding agent? Follow https://www.versionstory.com/developers/quickstart.md: it signs the engineer in, gets an API key, and puts the PDF redline in their app first.

Two endpoints do the work: one submits a pair of documents, the other polls for the finished
redline and hands back signed download links. A third, for organizations that opt into
[zero-day retention](#zero-day-retention), deletes a comparison as soon as you have its result.

| | |
| --- | --- |
| Base URL | `https://api.versionstory.com` |
| Auth | `Authorization: Bearer vs_...`. See [Authentication](/developers/authentication) |

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 on the response.

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

## Why the PDF

Engineers new to legal tend to see a redline as a diff. Lawyers know it is a deliverable their
clients judge them by. So the first thing to build is the PDF redline, shown where your lawyers
work.

- **It is the record.** Lawyers make the record. A litigator asks that the record reflect what
  happened; a transactional lawyer builds the record of a deal as it goes: what was sent, when,
  and what changed. A PDF redline is that record at one moment, the changes between two drafts
  exactly as they stood, and it reads the same in five years. A view your app draws for itself is
  not a record: it changes whenever your code does.
- **It looks right everywhere.** A PDF is a portable document: the same pages in your app, in an
  agent's chat, in a Slack thread, attached to an email, in a client portal, and on paper. There is
  no viewer to build, and nothing to redraw when your stack changes.
- **It is what lawyers review.** Clean, crisp pages, with page numbers to cite on a call: page 7,
  the second paragraph.
- **It is what clients see.** A redline sent to a client carries the firm's name. As Mitch
  Duncombe of Vector Legal put it: "Beautiful redlines matter to a law firm as a business. When we
  present to a company, we want polished deliverables, and Version Story's PDF comparisons embed
  right in our client portal."

Each other format has its own job: the [embedded HTML](/developers/rest/embed) for working through
the changes one by one, the Word file for negotiating on, the Markdown for a model to read, and the
JSON for code that acts on the changes.

## The flow

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

Generation is asynchronous. Neither call blocks: the first stages the work, and the second is a
single-shot readiness check you repeat.

## POST /v1/compare

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

| 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. Default: the revised document's file name |
| `preserve_track_changes` | No | `true` keeps the tracked changes already in both documents as they are; see [Keeping tracked changes](#keeping-tracked-changes). Default `false` |
| `show_formatting_changes` | No | `true` also tracks formatting and table layout changes; see [Formatting changes](#formatting-changes). Default `false` |
| `track_redline_changes` | No | `true` turns a redline PDF's insertions and deletions into tracked changes, so the comparison reads it as its revised text; see [Redline PDFs](/developers/reference/formats-and-limits#redline-pdfs). Default `true` |

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

### What you can compare

- **Documents:** `.docx`, `.doc`, or `.pdf`, in any combination. Sources that are not already
  `.docx` are converted first, so `.doc` and `.pdf` comparisons spend longer in `processing`.
- **Presentations:** two `.pptx` files make a native presentation comparison, whose redline is
  itself a `.pptx` (see [Formats](#formats)). A presentation can only be compared with another
  presentation; a `.pptx` paired with anything else is refused.
- **Workbooks:** two `.xlsx` files make a native Excel comparison, whose redline is itself a
  `.xlsx`. A workbook can only be compared with another workbook; a `.xlsx` paired with anything
  else is refused.
- Each file can be up to **100 MB**.
- Both files may share a name; two revisions of `contract.docx` is a normal case, and the revised
  side is disambiguated automatically.

### 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, the changes are attributed to the
revised file's name. Pass the name your reviewers should see instead.

### Keeping tracked changes

By default the comparison treats every tracked change in both documents as accepted, as Word's
Compare and Litera do, so proposed changes become part of the text being compared. Set
`preserve_track_changes=true` to keep them as proposals:

- **Tracked changes in either file stay.** Each tracked change already in `original` or in
  `modified` is kept as it is, under its own author and date.
- **Only real differences are added.** What `modified` changes beyond its own tracked changes, such
  as edits made with Track Changes off, is added as new tracked changes under `author`. Text that
  reads the same in both is not marked again.

The usual case is a counterparty's draft. It arrives with their tracked changes, and you edit it,
with Track Changes on or off. Send their draft as `original` and your copy as `modified`. The
redline shows their changes under their names, your tracked changes under yours, and your untracked
edits under `author`. Accepting every change gives your copy with its changes accepted; rejecting
every change gives the draft as it was before anyone's tracked changes.

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

It applies to Word documents; a pair of presentations or workbooks with it set is refused with
`INVALID_REQUEST`.

### Formatting changes

By default the redline tracks what the text says, not how it looks: a change of font, size, bold,
color, alignment, spacing or table layout is not marked. Set `show_formatting_changes=true` to
track those changes too.

| Applies to | |
| --- | --- |
| Word documents | Yes |
| Presentations and workbooks | No: the request is refused with `INVALID_REQUEST` |
| With `preserve_track_changes=true` | No: the field has no effect |
| A document converted from a PDF | No: the field has no effect |

### Response: 202 Accepted

```json
{
  "comparison_id": "cmp_MTp2cy0xOnZtLTE",
  "status": "processing",
  "file": {
    "file_id": "3f2c...",
    "file_name": "Redline - nda_v1 to nda_v2.docx",
    "status": "processing",
    "pending_formats": ["docx"]
  }
}
```

`comparison_id` is opaque and stable. Store it: it is the handle to the comparison and its
renderings. `file` is the Word redline as a [file](/developers/rest/files), the same object
`GET /v1/files/{file_id}` answers with, so you can also [comment on its changes](#commenting-on-the-redlines-changes)
or edit it. A presentation's redline is a file too, served as `pptx` and `pdf`, and a workbook's as
`xlsx`; neither has a listing, so neither can be edited.

### Errors

| Status | Code | Cause |
| --- | --- | --- |
| 400 | `INVALID_REQUEST` | A file field is missing, empty, or has no file name, or `preserve_track_changes` or `show_formatting_changes` is set for presentations or workbooks |
| 400 | `UNSUPPORTED_FILE_TYPE` | An extension is not `.docx`, `.doc`, `.pdf`, `.pptx` or `.xlsx`, or only one of the two files is a `.pptx` or a `.xlsx` |
| 400 | `DOCUMENT_UNREADABLE` | A `.docx` source is not a readable Word file: corrupt, or encrypted |
| 401 | `INVALID_API_KEY` | Missing, malformed, unknown, revoked, or expired key |
| 402 | `USAGE_LIMIT_REACHED` | The organization's monthly upload allowance is exhausted |
| 403 | `API_KEY_ORG_MISMATCH` | The key's service account no longer belongs to the key's organization |
| 403 | `TRIAL_COMPARISON_LIMIT_REACHED` | The organization's [API trial](/developers/reference/formats-and-limits#api-trials) allowance is used up |
| 413 | `FILE_TOO_LARGE` | A file exceeds the 100 MB limit |
| 429 | `TRIAL_RATE_LIMITED` | The trial's per-minute rate is exceeded; wait for `Retry-After` seconds |

Usage and trial checks run before anything is created, so a refused request costs nothing.

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

Reports whether the redline exists yet, and returns signed download links once it does. Safe to
call as often as you like: it creates nothing.

| Parameter | In | Required | Default | Meaning |
| --- | --- | --- | --- | --- |
| `comparison_id` | path | Yes | — | The id from `POST /v1/compare` |
| `format` | query | No | `docx` (`pptx` for presentations, `xlsx` for workbooks) | Which renderings to return. Name `pdf` to show the redline |

### Formats

`format` is repeatable, and also accepts a comma-separated list, so one call can ask for several
renderings: `?format=pdf&format=docx` and `?format=pdf,docx` are equivalent. A request that names
no format gets the Word file, which is for negotiating on, not for showing.

| `format` | Artifact | Use it for |
| --- | --- | --- |
| `pdf` | PDF rendering of the redline | Showing a person the redline. **Start here** |
| `html` | The interactive redline as one self-contained HTML file. See [Embed redlines](/developers/rest/embed) | An interactive view beside the PDF: change cards, filters, Previous and Next |
| `docx` | Word document with real tracked changes (the default) | A lawyer to accept, reject and send on in Word |
| `pdf_changed_pages_only` | PDF containing only the pages that changed | A quick review of a long agreement |
| `md` | The redline as Markdown, with every change marked inline. See [Redline format](/developers/reference/redline-format) | A model to read: summaries, questions about the changes, checks against a playbook |
| `json` | The redline as structured data. See [Document JSON](/developers/reference/json-format) | Code that acts on the changes: counting them, routing a document, linking a change to your own records. Not for display |

Do not build a redline viewer from `json`. It describes the document's structure and its changes,
not how the document is laid out in pages, so a view drawn from it re-implements Word's layout and
still shows less than the PDF and the HTML redline do. Show the PDF, and embed the HTML where the
view must be interactive.

A presentation comparison offers `pptx` (a presentation with the changes marked, the default) and
`pdf`, and an Excel comparison `xlsx` (a workbook with the changes marked) only. Asking either for
any other format is an `UNSUPPORTED_FORMAT` error.

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

### Response: ready

`file` holds the redline's downloads, one per requested format, in the order you asked for them.

```json
{
  "comparison_id": "cmp_MTp2cy0xOnZtLTE",
  "status": "ready",
  "file": {
    "file_id": "3f2c...",
    "file_name": "Redline - nda_v1 to nda_v2.docx",
    "status": "ready",
    "downloads": [
      {
        "format": "pdf",
        "url": "https://documents.versionstory.com/...",
        "file_name": "Redline - nda_v1 to nda_v2.pdf",
        "expires_at": "2026-10-22T22:00:00Z"
      },
      {
        "format": "docx",
        "url": "https://documents.versionstory.com/...",
        "file_name": "Redline - nda_v1 to nda_v2.docx",
        "expires_at": "2026-10-22T22:00:00Z"
      }
    ]
  }
}
```

Each `url` is signed and needs no `Authorization` header: fetch it directly, saving the response
as a binary file. It stops working at `expires_at`, one hour after it was issued, so store the
`comparison_id` or the file, never the link. Poll again at any time for fresh links; the redline
itself does not expire, unless the organization uses [zero-day retention](#zero-day-retention).

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

`status` is the comparison's, and `file.status` the redline file's. A failed comparison has no
`file`.

### 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 `file.pending_formats`. `status` is `ready` only once every format you asked for is
available.

```json
{
  "comparison_id": "cmp_MTp2cy0xOnZtLTE",
  "status": "processing",
  "file": {
    "file_id": "3f2c...",
    "file_name": "Redline - nda_v1 to nda_v2.docx",
    "status": "processing",
    "downloads": [
      {
        "format": "docx",
        "url": "https://documents.versionstory.com/...",
        "file_name": "Redline - nda_v1 to nda_v2.docx",
        "expires_at": "2026-10-22T22:00:00Z"
      }
    ],
    "pending_formats": ["pdf"]
  }
}
```

### Response: failed

Terminal. The comparison will not succeed on its own, and waiting longer changes nothing; submit a
new comparison to retry. There are two shapes.

**The redline itself failed.** `error` says why, and there is no `file`:

```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` or `.pdf` 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 its PDF rendering did not |
| `COMPARISON_FAILED` | Catch-all; `upstream_code` carries the specific pipeline code |

**The redline succeeded but a rendering of it did not.** Everything that was produced is still in
`file.downloads`, and the formats that will never arrive are named in `file.failed_formats`.
Usable files are usable; only the named formats are missing.

Either way, a *failed comparison* is reported as `200 OK` with `"status": "failed"`, because the
request succeeded: it correctly told you what 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, or is a `mrg_` merge id |
| 400 | `UNSUPPORTED_FORMAT` | A requested `format` is unknown, or not available for this kind of comparison |
| 403 | `TRIAL_WORD_DOWNLOADS_EXCLUDED` | `docx` was requested, and the organization's API trial excludes Word downloads |
| 404 | `COMPARISON_NOT_FOUND` | No such comparison, or it belongs to another organization |
| 410 | `CONTENT_DELETED` | The comparison's content was deleted under [zero-day retention](#zero-day-retention) |

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

### Fields kept for existing integrations

Responses also carry `redline_file_id`, `downloads`, `pending_formats` and `failed_formats` at the
top level. They are deprecated, and marked so in the OpenAPI specification. New code reads `file`:
`redline_file_id` is `file.file_id` (absent for a presentation or workbook comparison), and the
top-level `downloads` serves every format `file` does, with two differences:

- **`json`** there is the comparison's
  [older JSON](/developers/reference/json-format#the-older-comparison-json), kept for the
  integrations built on it. The `json` in `file` is the redline's listing.
- **`expires_at`** there keeps its older form, with `+00:00` and fractional seconds. Every other time
  ends in `Z`.

## Commenting on the redline's changes

A Word comparison's redline is also a file you can read and edit, named by `file.file_id`. To
annotate its tracked changes, such as noting why each change was made, comment on each change by
its index, with no download and upload:

1. `GET /v1/files/{file_id}?format=json` for the redline's `file.file_id` lists its tracked changes
   in `revisions`, each with its `index`, `type`, `author` and the start of its `text`. The format
   is pending until the redline exists.
2. `POST /v1/edit` with that `file_id` and one
   [`add_comment`](/developers/rest/edit-operations#addcomment) per change:
   `{"type": "add_comment", "revision": {"index": 2}, "content": "..."}`.
3. Download the result's `files.comparison`: the comparison's tracked changes with your comments
   beside them.

```bash
curl -sS https://api.versionstory.com/v1/edit \
  -H "Authorization: Bearer vs_live_..." \
  -H "Content-Type: application/json" \
  -d '{"file_id": "3f2c...", "schema_version": 1, "author": "Dana Reyes",
       "wait": true, "outputs": ["pdf", "docx"],
       "edits": [
         {"type": "add_comment", "revision": {"index": 0}, "content": "Section 1 now defines the services."},
         {"type": "add_comment", "revision": {"index": 2}, "content": "45 days matches the client AP cycle."}]}'
```

Each comment covers what its change changed: the text inserted, deleted or moved (at the move's
destination), or the paragraph, row or cell whose formatting changed. See
[`add_comment`](/developers/rest/edit-operations#addcomment).

## Zero-day retention

By default, a comparison's documents and redline are kept, and you can come back for fresh
download links at any time. Organizations that would rather keep nothing can turn on **zero-day
retention** in the web app, on the **Developer** page's Overview tab.

With it on, every new `POST /v1/compare` comparison is deleted permanently once you have its
result. The source documents, the redline, and every rendering of it are removed; only a record
that the comparison existed remains. Deletion happens at whichever comes first:

- **One hour after the comparison finishes.** Each download's `expires_at` is that deadline, so it
  tells you exactly how long you have.
- **When you acknowledge the download**, with the endpoint below. Call it as soon as you have saved
  what you need.

After deletion, `GET /v1/compare/{comparison_id}` answers `410 CONTENT_DELETED`.

The setting is stamped onto each comparison when it is created: turning it on or off never changes
comparisons that already exist. Merges, edits, uploaded files and created documents are deleted on
the same terms, each with its own acknowledge call; see
[Data handling](/developers/reference/data-handling#retention).

### POST /v1/compare/&#123;comparison_id&#125;/acknowledge

Tells Version Story you have downloaded the result, so it can delete the comparison now rather
than at the deadline. No body.

```bash
curl -sS -X POST https://api.versionstory.com/v1/compare/cmp_MTp2cy0xOnZtLTE/acknowledge \
  -H "Authorization: Bearer vs_live_..."
```

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

The `202` means deletion is queued; it completes moments later.

| Status | Code | Cause |
| --- | --- | --- |
| 400 | `INVALID_COMPARISON_ID` | The id is malformed |
| 404 | `COMPARISON_NOT_FOUND` | No such comparison, or it belongs to another organization |
| 409 | `CONFLICT` | The comparison is still processing, or was created without zero-day retention |
| 410 | `CONTENT_DELETED` | The comparison has already been deleted |

## 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 = ["pdf"], 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 });
    const body = await response.json();
    if (!response.ok) {
      throw new Error(`${body.error.code}: ${body.error.message} (${body.error.request_id})`);
    }

    if (body.status === "ready") return body.file.downloads;
    if (body.status === "failed") {
      throw new Error(`Comparison failed: ${body.error?.code ?? `no ${body.file.failed_formats.join(", ")}`}`);
    }

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

## Related

- [Embed redlines](/developers/rest/embed): the interactive HTML redline, beside the PDF
- [Merge](/developers/rest/merge): several people's edits to one document, in one draft
- [Formats & limits](/developers/reference/formats-and-limits): file types, size caps, trials

---

<!-- https://www.versionstory.com/developers/rest/embed -->

# Embed redlines

> Frame a comparison's redline, or a single document, as one self-contained HTML file in your own app, with Version Story's Revisions panel in light or dark, or your own navigation and cards.

> Integrating Version Story into a codebase as a coding agent? Follow https://www.versionstory.com/developers/quickstart.md: it signs the engineer in, gets an API key, and puts the PDF redline in their app first.

Keep the [PDF](/developers/rest/compare#why-the-pdf) as the default view: it is the redline exactly
as a lawyer prints, sends and keeps it. Add the embedded HTML beside it where your lawyers need to
work through the changes one by one.

The HTML is the same redline laid out in pages, with the Version Story app's Revisions panel beside
them: change and comment cards, Filters, and Previous and Next, in a light or a dark theme. It is one
self-contained file, with its script, styles and images inlined, served from a signed link that you
put in a sandboxed `iframe`. Each part of the panel can be turned off, so your page can draw its own
instead and drive the document over `postMessage`.

| | |
| --- | --- |
| Base URL | `https://api.versionstory.com` |
| Auth | `Authorization: Bearer vs_...`. See [Authentication](/developers/authentication) |
| Works with | Word comparisons from [`POST /v1/compare`](/developers/rest/compare), and any `.docx`, `.doc` or `.pdf` [file](/developers/rest/files) |

## How it fits together

```text
Your server ── GET /v1/compare/{id}?format=html (API key) ──▶ Version Story
     │                                                       answers with a signed link
     ▼
Your page  ── <iframe sandbox="allow-scripts" src="{link}#embed_origin=…"> ── the redline
     ▲                                                                         │
     └──────────────────── postMessage (vs:* messages) ────────────────────────┘
```

1. Create a comparison with [`POST /v1/compare`](/developers/rest/compare).
2. **On your server**, ask for `GET /v1/compare/{comparison_id}?format=html` with your API key, and
   take the link from `file.downloads[].url`. The API key never goes to the browser.
3. **On your page**, frame the link with `sandbox="allow-scripts"`, and put the options in its
   fragment: at least `embed_origin`, your page's origin.
4. Optionally, talk to the document: tell it to go to the next change or to a paragraph, and listen
   for where it is. See [Messages](#messages).

## Getting the link

`format=html` is one of the formats `GET /v1/compare/{comparison_id}` serves for a Word comparison,
and that [`GET /v1/files/{file_id}`](/developers/rest/files#get-v1filesfileid) serves for any
document:

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

```json
{
  "comparison_id": "cmp_MTp2cy0xOnZtLTE",
  "status": "ready",
  "file": {
    "file_id": "3f2c...",
    "file_name": "Redline - nda_v1 to nda_v2.docx",
    "status": "ready",
    "downloads": [
      { "format": "pdf", "url": "https://documents.versionstory.com/...", "file_name": "Redline - nda_v1 to nda_v2.pdf", "expires_at": "..." },
      { "format": "html", "url": "https://documents.versionstory.com/....embed.html?...", "file_name": "Redline - nda_v1 to nda_v2.html", "expires_at": "..." }
    ]
  }
}
```

- **A comparison's HTML** is made with its redline and trails the PDF by a few seconds. Until it
  exists, `html` is in `file.pending_formats`; poll after `Retry-After`.
- **A document's HTML** is made the first time it is asked for, or when the file is opened in the
  app, and is pending until then. It shows the tracked changes and comments the file itself holds.
- **The link is signed** and valid for one hour, like every download link. Ask for a fresh one each
  time someone opens the redline rather than storing it.
- One that can no longer be made is in `failed_formats`. Presentations and workbooks have no HTML.

The link serves the file as `text/html` with `Content-Security-Policy: sandbox allow-scripts` and
`X-Content-Type-Options: nosniff`, so even opened directly in a tab it runs sandboxed, with an opaque
origin. The file makes no requests to Version Story; its only external requests are to Google Fonts,
for the document's fonts.

## Embedding the document

Frame the link with `sandbox="allow-scripts"`, and put the options in its fragment:

```html
<iframe
  sandbox="allow-scripts"
  title="Redline"
  src="{url}#embed_origin=https://app.example.com&navigator=false&comment_cards=true">
</iframe>
```

| Fragment key | Value | Default |
| --- | --- | --- |
| `embed_origin` | The embedding page's origin, exactly as the browser reports `window.location.origin`: `https://` with a host and optional port, or `http://localhost` and `http://127.0.0.1` for development; no path. The document exchanges messages only with a parent at this origin | none: no messages |
| `navigator` | `true` or `false` (or `1` or `0`): the Revisions panel's controls, its Filters (insertions, deletions, comments and, in a merge, conflicts) and Previous and Next | `true` |
| `sidebar_change_cards` | Each change's card (and each conflict's) in the Revisions panel | `true` |
| `inline_change_cards` | A selected change's card over the document, just below the change, while the panel does not list the changes' cards | `true` |
| `comment_cards` | The comments' cards in the Revisions panel. The commented text stays highlighted either way | `true` |

The options go in the fragment, not the query: extra query parameters break the link's signature,
and a fragment is never sent to a server. Any other key or value is ignored. The four display options
set what the document draws when it loads; your page can change any of them afterwards with
[`vs:setOptions`](#messages-your-page-sends). The Revisions panel shows while `navigator`,
`sidebar_change_cards` or `comment_cards` is on; with all three off, the pages take the frame's full
width. Give the frame a height; the document scrolls inside it.

The document runs script built from your customer's file, so sandbox it:

- **Use exactly `sandbox="allow-scripts"`.** Never add `allow-same-origin`. In the sandbox the
  document runs with an opaque origin, under the Content-Security-Policy it carries first in its head,
  which runs only its own scripts and loads nothing but Google Fonts. It cannot read your page, its
  cookies or storage, or navigate it.
- **Never insert the HTML into your own page's DOM** (for example with `innerHTML`). Keep it in the
  frame.
- **Keep the API key on your server.** The browser only ever receives the signed link.
- **If your page sends a Content-Security-Policy,** allow the frame: `frame-src https://documents.versionstory.com`.

## Messages

Your page and the document talk with `window.postMessage`. Every message is an object with a `type`
starting `vs:`. Messages are exchanged only when the fragment named `embed_origin` and your page, the
frame's parent, is at exactly that origin.

**Sending.** The sandboxed document has an opaque origin, which cannot be named as a target, so send
with `"*"`:

```javascript
frame.contentWindow.postMessage({ type: "vs:next" }, "*");
```

**Receiving.** A sandboxed frame reports its origin as the string `"null"`, so identify its messages
by their source window:

```javascript
window.addEventListener("message", (event) => {
  if (event.source !== frame.contentWindow) return;
  const message = event.data;
  // message.type is one of the vs:* messages below
});
```

The text and author names in `vs:changes` come from your customer's document. Put them in your page
as text (`textContent`), never as HTML.

### Messages the document sends

| `type` | Fields | When |
| --- | --- | --- |
| `vs:changes` | `summary`, `changes` | Once the document has read its changes and comments, before `vs:ready` |
| `vs:ready` | | The document has laid out its pages. Elements you scroll to from now on stay where they are |
| `vs:highlighted` | `anchorId`, `index`, `total` | Every time the selection moves, however it moved: your messages, Previous or Next in the Revisions panel, a card, or a click on a change or comment in the document |
| `vs:optionsChanged` | `options` | The reader changed a filter in the Revisions panel |

**`vs:changes`**

```json
{
  "type": "vs:changes",
  "summary": { "insertions": 9, "deletions": 4, "moves": 0, "comments": 3, "merge_conflicts": 0 },
  "changes": [
    { "anchorId": "28", "kind": "change", "changeType": "insert", "text": "here is a bolded change", "author": "Dana Reyes" },
    { "anchorId": "54d40fe821ef4c13870e2418118ceb12", "kind": "comment", "changeType": "comment", "text": "Here is a comment on v1 NDA", "author": "Corporate Counsel" }
  ]
}
```

| Field | Meaning |
| --- | --- |
| `anchorId` | The element's id in the document. Pass it to `vs:scrollTo` to go to it |
| `kind` | `change`, `comment` or `merge_conflict` |
| `changeType` | `insert`, `delete`, `move`, `moveFrom` or `moveTo` for a change; `comment` for a comment; `null` for a conflict |
| `text` | The changed or commented text (for a conflict, its changes' texts joined by ` / `) |
| `author` | The author's name, without the source document's name |

Changes and comments come in document order.

**`vs:highlighted`**

```json
{ "type": "vs:highlighted", "anchorId": "31", "index": 1, "total": 9 }
```

| Field | Meaning |
| --- | --- |
| `anchorId` | The element now selected. The selection moves from one element to another; it is never cleared |
| `index` | Its zero-based position among the elements Previous and Next stop at, or `-1` when the selected element is not one of them (a paragraph) |
| `total` | How many elements Previous and Next stop at |

Previous and Next stop at tracked changes, comments, conflicts and footnote references, and skip the
kinds that are hidden. Use `index` and `total` for a counter such as "2 of 9":
`${index + 1} of ${total}`.

**`vs:optionsChanged`**

```json
{ "type": "vs:optionsChanged", "options": { "showInsertions": false, "showDeletions": true, "showComments": true, "showMergeConflicts": true, "showChangeCards": true } }
```

Sent when the reader changes a filter in the Revisions panel, with every one of these options as it
now is, so your controls can stay in step by taking the message as a whole. Changes your page makes
with `vs:setOptions` are not echoed back.

### Messages your page sends

| `type` | Fields | Effect |
| --- | --- | --- |
| `vs:scrollTo` | `anchorId` | Scrolls to the element and selects it. Sent before `vs:ready`, it is held and applied once the pages are laid out (the last one sent wins) |
| `vs:next` | | Selects the next change, wrapping around at the end |
| `vs:previous` | | Selects the previous change, wrapping around at the start |
| `vs:setOptions` | `options` | Changes what the document shows. Any subset of the options below; anything else is ignored |

`anchorId` must be 1 to 128 letters, digits, `-` or `_`; other values are ignored. Each move
produces a `vs:highlighted`.

```javascript
frame.contentWindow.postMessage(
  { type: "vs:setOptions", options: { showInsertions: false, theme: "dark" } },
  "*",
);
```

| Option | Effect |
| --- | --- |
| `showInsertions` | Show insertions in the text |
| `showDeletions` | Show deletions in the text |
| `showComments` | Show comments: the panel's Comments filter. The commented text stays highlighted |
| `showMergeConflicts` | Show conflicts, where versions changed the same text, as one card each (merges only); off, their changes are listed one by one |
| `showControls` | Show the panel's Filters and Previous and Next (the `navigator` option) |
| `showChangeCards` | Show change cards in the Revisions panel (`sidebar_change_cards`) |
| `showInlineChangeCards` | Show a selected change's card over the document (`inline_change_cards`) |
| `showCommentCards` | Show comment cards in the Revisions panel (`comment_cards`) |
| `theme` | `"light"` or `"dark"`: the Revisions panel's theme (see [Themes](#themes)) |

Every option but `theme` is a boolean. Each takes effect immediately.

## Themes

The Revisions panel, its cards and the space around the pages come in the Version Story app's light
and dark themes. The document opens in light; send `vs:setOptions` with `theme: "dark"` to switch,
before or after `vs:ready`, for example to follow your page's own theme:

```javascript
const dark = window.matchMedia("(prefers-color-scheme: dark)");
const setTheme = () =>
  frame.contentWindow.postMessage({ type: "vs:setOptions", options: { theme: dark.matches ? "dark" : "light" } }, "*");
dark.addEventListener("change", setTheme);
frame.addEventListener("load", setTheme);
```

The pages themselves are the same in both themes: white, with the document's own colors and its
tracked changes as Version Story marks them.

## Linking to a place in the document

`vs:scrollTo` accepts any element id the document carries. Two kinds are useful:

- **A change or comment:** the `anchorId` of an item in `vs:changes`.
- **A paragraph:** the paragraph's `id` in the redline's [JSON](/developers/reference/json-format)
  (`blocks[].id`). The JSON and the HTML give each paragraph the same id, so a paragraph your code
  found in the JSON can be shown in the document.

A tracked change in the JSON names its revision's `index`, not an id the HTML carries. To link to a
single change, use the `anchorId`s from `vs:changes`.

## Bringing your own navigator

To draw your own change navigator in your page and keep Version Story's cards:

1. Frame the link with `navigator=false` in its fragment.
2. Draw Previous and Next buttons that send `vs:previous` and `vs:next`.
3. Update a counter from each `vs:highlighted`.
4. For your own filters, send `vs:setOptions` with `showInsertions` or `showDeletions`.

To list changes in your own sidebar instead of Version Story's cards, also set
`sidebar_change_cards=false`, `comment_cards=false` and `inline_change_cards=false`, build your list
from `vs:changes`, and send `vs:scrollTo` when an item is clicked.

## Zero-day retention

For an organization with [zero-day retention](/developers/reference/data-handling#retention), the
link stops working at the comparison's deadline, when its files are deleted. To keep showing the
redline after that, download the HTML before you acknowledge the comparison
(`curl -L "$URL" -o redline.html`) and serve it from your own host as `text/html`. The same frame and
fragment options work on any host:
`<iframe sandbox="allow-scripts" src="https://your.host/redline.html#embed_origin=...">`.

## Example

A complete embedding: a Node server (18 or later, no dependencies) that keeps the API key and hands
the page a fresh link, and a page with its own navigator.

```javascript
// server.mjs: run with VERSION_STORY_API_KEY=vs_... node server.mjs, then open
// http://localhost:3000/?comparison_id=cmp_...
import http from "node:http";
import { readFile } from "node:fs/promises";

const API = "https://api.versionstory.com";
const headers = { Authorization: `Bearer ${process.env.VERSION_STORY_API_KEY}` };
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// A fresh signed link to the redline's HTML, waiting while it is still being made.
async function redlineLink(comparisonId) {
  const url = `${API}/v1/compare/${encodeURIComponent(comparisonId)}?format=html`;
  for (let attempt = 0; attempt < 60; attempt++) {
    const response = await fetch(url, { headers });
    const body = await response.json();
    if (!response.ok) return { status: response.status, body };
    if (body.status === "ready") return { status: 200, body: { url: body.file.downloads[0].url } };
    if (body.status === "failed") return { status: 502, body: { error: body.error ?? { code: "HTML_FAILED" } } };
    await sleep(Number(response.headers.get("Retry-After") ?? 1) * 1000);
  }
  return { status: 504, body: { error: { code: "TIMEOUT", message: "The redline did not become ready" } } };
}

http
  .createServer(async (request, response) => {
    const url = new URL(request.url, "http://localhost:3000");
    if (url.pathname === "/") {
      response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
      return response.end(await readFile(new URL("./index.html", import.meta.url)));
    }
    if (url.pathname === "/redline-link") {
      const { status, body } = await redlineLink(url.searchParams.get("comparison_id"));
      response.writeHead(status, { "Content-Type": "application/json" });
      return response.end(JSON.stringify(body));
    }
    response.writeHead(404).end();
  })
  .listen(3000);
```

```html
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8" />
  <style>
    body { margin: 0; display: flex; flex-direction: column; height: 100vh; font-family: sans-serif; }
    nav { display: flex; gap: 12px; align-items: center; padding: 8px 16px; border-bottom: 1px solid #ddd; }
    iframe { flex: 1; border: 0; }
  </style>
</head>
<body>
  <nav>
    <button id="previous" disabled>Previous</button>
    <span id="position">Loading…</span>
    <button id="next" disabled>Next</button>
    <label><input id="insertions" type="checkbox" checked /> Insertions</label>
    <label><input id="deletions" type="checkbox" checked /> Deletions</label>
    <label><input id="dark" type="checkbox" /> Dark</label>
  </nav>
  <iframe id="redline" title="Redline" sandbox="allow-scripts"></iframe>
  <script>
    const frame = document.getElementById("redline");
    const send = (message) => frame.contentWindow.postMessage(message, "*");

    window.addEventListener("message", (event) => {
      if (event.source !== frame.contentWindow) return;
      const message = event.data;
      if (message.type === "vs:changes") {
        document.getElementById("position").textContent = `${message.summary.insertions + message.summary.deletions} changes`;
      } else if (message.type === "vs:ready") {
        document.getElementById("previous").disabled = false;
        document.getElementById("next").disabled = false;
      } else if (message.type === "vs:optionsChanged") {
        document.getElementById("insertions").checked = message.options.showInsertions;
        document.getElementById("deletions").checked = message.options.showDeletions;
      } else if (message.type === "vs:highlighted") {
        document.getElementById("position").textContent =
          message.index >= 0 ? `${message.index + 1} of ${message.total}` : `– of ${message.total}`;
      }
    });

    document.getElementById("previous").onclick = () => send({ type: "vs:previous" });
    document.getElementById("next").onclick = () => send({ type: "vs:next" });
    document.getElementById("insertions").onchange = (event) =>
      send({ type: "vs:setOptions", options: { showInsertions: event.target.checked } });
    document.getElementById("deletions").onchange = (event) =>
      send({ type: "vs:setOptions", options: { showDeletions: event.target.checked } });
    document.getElementById("dark").onchange = (event) =>
      send({ type: "vs:setOptions", options: { theme: event.target.checked ? "dark" : "light" } });

    const comparisonId = new URLSearchParams(location.search).get("comparison_id");
    fetch(`/redline-link?comparison_id=${encodeURIComponent(comparisonId)}`)
      .then((response) => response.json())
      .then((body) => {
        if (body.error) throw new Error(`${body.error.code}: ${body.error.message ?? ""}`);
        const options = new URLSearchParams({ embed_origin: location.origin, navigator: "false" });
        frame.src = `${body.url}#${options}`;
      })
      .catch((error) => (document.getElementById("position").textContent = error.message));
  </script>
</body>
</html>
```

## Limits

- Word comparisons from `POST /v1/compare`, and `.docx`, `.doc` or `.pdf` files. Presentations
  (`.pptx`) and workbooks (`.xlsx`) have no HTML.
- The document is laid out in the browser. A long redline with thousands of changes can take several
  seconds to reach `vs:ready`.
- Comment cards show each comment with its replies, and mark comments and replies added or deleted
  between the versions, and comments on text the later version deleted. `vs:changes` lists replies as
  separate comments.

## Related

- [Compare](/developers/rest/compare#why-the-pdf): why the PDF stays the default view
- [Files](/developers/rest/files): a single document's HTML, and every other format

---

<!-- https://www.versionstory.com/developers/rest/edit -->

# Edit

> Upload a Word document, read its listing, send edits, and download the edited document and a redline of what changed.

> Integrating Version Story into a codebase as a coding agent? Follow https://www.versionstory.com/developers/quickstart.md: it signs the engineer in, gets an API key, and puts the PDF redline in their app first.

Upload a `.docx`, read its listing, a list of its paragraphs with the address of each, then send
edits that name the paragraphs to change by those addresses. The result is two files: the edited
document, clean, with the edits accepted, and a redline in which every edit is a real Word tracked
change that a reviewer can accept or reject.

| | |
| --- | --- |
| Base URL | `https://api.versionstory.com` |
| Auth | `Authorization: Bearer vs_...`. See [Authentication](/developers/authentication) |

Non-2xx responses use the same envelope as [Compare](/developers/rest/compare), and `X-Request-Id`
works the same way.

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

## The flow

An edit says which paragraph to change by its address, such as "replace `[Company Name]` in
paragraph 7". So before you can edit, you need the document stored with us and its listing:

1. **Upload it.** [`POST /v1/files`](/developers/rest/files#post-v1files) with the `.docx` returns a
   `file_id`, which every later call uses to name the document.
2. **Read it.** `GET /v1/files/{file_id}?format=md` serves the document's listing as a download: its
   text, with every paragraph's address and the addresses of its headers, footers, notes, comments,
   tables and tracked changes. Find what you want to change and copy its address.
3. **Edit it.** `POST /v1/edit` with the `file_id` and a list of `edits` that use those addresses.
   Returns an `edit_id` and, with `wait`, the finished result.
4. **Get the result.** `GET /v1/edit/{edit_id}` until it is ready, if you did not wait. Download the
   edited document and the redline, as a PDF to show and as Word to send on.
5. **Edit again.** For further changes, read the edited document's listing, `files.clean.file_id`
   in the edit response, and send the next `POST /v1/edit` with that `file_id`.

A `file_id` names one immutable document, so its listing never changes. An edit produces a new file
with its own `file_id`; a user's updated copy is a new upload.

## The listing

`GET /v1/files/{file_id}` serves a document's listing in two formats:

- **`md`**, the listing to write edits from. A header explains each tag it prints; then each
  paragraph is one line, in document order: `<p index="n" id="…"/>` and its text. Tables, tracked
  changes (`<ins>`, `<del>`, `<moved-from>`, `<moved-to>`, each with the `revision` index a decision
  names), fields, links, pictures, equations, note references and comment anchors print where they
  are. After the body come the headers and footers, notes, comment threads, every revision, and the
  styles.
- **`json`**, the same listing as structured data, for code. See
  [Document JSON](/developers/reference/json-format).

```bash
curl -sS "https://api.versionstory.com/v1/files/3f2a9c1e-...?format=md" \
  -H "Authorization: Bearer vs_live_..."
```

```json
{ "file_id": "3f2a9c1e-...", "file_name": "nda.docx", "status": "ready",
  "downloads": [{ "format": "md", "url": "https://documents.versionstory.com/...", "file_name": "nda.md", "expires_at": "..." }] }
```

The listing is made once, when the file is prepared, and stored with it. While it is being made the
format is in `pending_formats`; poll after `Retry-After`, then fetch its `url`. A long document's
listing can be searched, or read in parts, without downloading all of it: see
[Reading the listing](/developers/rest/files#reading-the-listing).

Copy addresses from the listing rather than counting them yourself:

- **Index and id.** Everything an edit names has an `index`, where it is now, counted from 0 within
  its container: a body paragraph among the body's paragraphs, table cells' paragraphs included; a
  header's, footer's or note's paragraphs within that part; tables within their part, rows within
  their table and cells within their row; and sections, notes of a kind, comment threads, pictures
  and revisions within the document. Paragraphs, tables, rows and cells also have an `id`, which they
  keep when content moves.
- **Parts.** Each header and footer prints its `part` locator (`kind`, `type` and the first `section`
  that shows it) and `sections`, every section that shows it. Each note prints a `part` (`kind` and
  `index`) and a `label`, the mark the reader sees.
- **Counting from 1.** Only what a reader counts: a list paragraph's `list_level` (1 to 9), a
  selector's `occurrence`, and table-of-contents levels. Every `index` counts from 0.
- **Revisions.** Existing tracked changes are listed one entry per accept or reject decision, each with
  an `index`, a `type` (`insertion`, `deletion`, `move` or `property`), `author`, `date`, `on` and
  `text`. A replacement is two entries: a deletion and an insertion.
- **Comments.** Listed as threads with their `replies`, each with its `index` in document order.

The listing is for writing edits and for code, not for showing a person the document. Show them the
PDF, or [embed the HTML](/developers/rest/embed) where the view must be interactive.

## POST /v1/edit

A JSON body. A request that uploads a style template or picture files is `multipart/form-data`
instead, with the same fields as form fields and `edits` and `outputs` as JSON strings.

| Field | Required | Meaning |
| --- | --- | --- |
| `file_id` | Yes | The document to edit: an upload, an earlier edit's `files.clean.file_id`, or a Word comparison's `file.file_id` (see [Commenting on the redline's changes](/developers/rest/compare#commenting-on-the-redlines-changes)) |
| `schema_version` | Yes | The listing's `schema_version`, `1`. A mismatch is `400 INVALID_EDIT`, so a change to how the listing counts paragraphs cannot silently shift your addresses |
| `edits` | Yes | 1 to 200 edit objects, applied in order. See [Edit operations](/developers/rest/edit-operations) |
| `author` | No | Name on every tracked change and comment the request makes. Default `Version Story AI` |
| `date` | No | ISO 8601 date and time for those changes. Default: the time of the request |
| `wait` | No | `true` holds the response up to 5 seconds and answers with the finished result; an edit still running after that answers `202` with its `edit_id` to poll |
| `outputs` | No | With `wait`: the formats of both result files, as `GET /v1/edit/{edit_id}`'s `format` takes them. Default `["docx"]` |
| `inline` | No | With `wait`: `true` adds each file's bytes as `content_base64` to each ready download |
| `description` | No | What the edits change, in one plain line, such as `"Extend the termination notice to 60 days"`. Kept with the version the edit becomes and shown in the Version Story app |
| `new_version` | No | `true` makes the edit a new version even where it would amend your working draft. See [Versions](#versions) |
| `amend` | No | `true` puts the change in the version this `file_id` is and in every version after it. See [Amending a version](#amending-a-version). At most one of `new_version` and `amend` |
| `version_name` | No | The version's name, the user's name for its file. At most 255 characters |
| `style_template` | No | Multipart only: a `.docx`, `.dotx` or `.dotm` whose styles and numbering are imported before the first edit |
| `pictures` | No | Multipart only, repeated: PNG, JPEG or GIF files that picture runs name by file name. See [Pictures](/developers/rest/edit-operations#pictures) |

The body is at most 208 KB, 200 KB of it edits. Unknown fields are refused. That includes `document`:
a document is staged with `POST /v1/files`, never sent with the edit.

```bash
curl -sS https://api.versionstory.com/v1/edit \
  -H "Authorization: Bearer vs_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "file_id": "3f2a9c1e-...",
    "schema_version": 1,
    "author": "Dana Reyes",
    "wait": true,
    "outputs": ["pdf", "docx"],
    "edits": [
      {
        "type": "replace",
        "paragraphs": { "index": 7 },
        "text": { "match": "[Company Name]" },
        "content": "Acme, Inc."
      }
    ]
  }'
```

### Attributing the changes

`author` is the name Word shows on every tracked change in the redline, and on any comment that does
not name its own author. It is one line of at most 255 characters with no control characters;
surrounding spaces are trimmed. A comment edit can carry its own `author`, which takes precedence for
that comment.

`date` takes an ISO 8601 timestamp such as `2026-10-05T14:30:00Z`. Send the author's UTC offset
(`2026-10-05T10:30:00-04:00`) if you want Word versions that read only the local clock time to show
local time; without an offset, the value is UTC. It is kept to the second.

### Waiting for the result

By default, `POST /v1/edit` answers `202` with the edit's ids and you poll. With `"wait": true`, the
request waits up to 5 seconds and the response is the same body `GET /v1/edit/{edit_id}` returns:
`200` when the edit finished, `202` if it is still processing, in which case you continue by polling.
`outputs` and `inline` apply only with `wait`.

## How edits address things

This is the short version; [Edit operations](/developers/rest/edit-operations#locators) has every
form.

An edit never uses a bare number. It names what it acts on with a locator object, an address such as
`{"index": 7}`, and the listing prints each locator so you can copy it.

### Locating paragraphs

Every operation that acts on paragraphs names them in one required field, `paragraphs`:

| Form | Names |
| --- | --- |
| `{"index": 12}` | Body paragraph 12 |
| `{"id": "8532bf246ab64e8788c26d27cfd105b4"}` | The paragraph with this id, in any part |
| `{"part": {"kind": "footnote", "index": 0}, "index": 0}` | The first paragraph of the first footnote |
| `{"part": {"kind": "footer", "type": "default", "section": {"index": 0}}, "index": 0}` | The first paragraph of the first section's default footer |
| `{"from": {"index": 9}, "through": {"index": 11}}` | The paragraphs between two locators, inclusive, within one part |
| `[{"index": 3}, {"from": ..., "through": ...}]` | An array of locators and ranges |
| `"all"` | Every paragraph of every part |
| `{"ref": "p1"}` | A paragraph this same request created |

There is no implicit scope: a whole-document replacement says `"paragraphs": "all"`. Other things
have locators of the same style: a section is `{"index": 1}`, a table is `{"index": 1}` or
`{"id": "…"}`, a row is `{"table": {"index": 1}, "index": 3}` or `{"id": "…"}`, a column is
`{"table": {"index": 1}, "index": 2}`, a cell is `{"row": {...}, "index": 0}` or `{"id": "…"}`, a
comment thread is `{"index": 2}`, a note is `{"kind": "footnote", "index": 2}`, and an existing
tracked change is `{"index": 4}`, `"all"`, or a filter by `author` and `type`.

### Selecting text

`text` selects text inside the named paragraphs. `at` names an insertion point.

```json
"text": { "match": "Series A Preferred Stock", "prefix": "the ", "suffix": " issued", "occurrence": 2 }
"at": "start"
"at": { "after": { "match": "Name:\t" } }
```

- **`match`** is the text to find. It is case-sensitive, and a match never crosses a paragraph
  boundary. Curly and straight quotes, en and em dashes and hyphens, and non-breaking and ordinary
  spaces are read alike.
- **`prefix` and `suffix`** are optional context that must sit immediately before and after the
  match. They narrow the candidates; the edit never acts on them.
- **`occurrence`** picks among several matches, counted across every paragraph the edit names: `N`
  for the Nth, or `"all"` for every one. Omitted, the match must be unique.
- **`at`** is `"start"`, `"end"`, or `{"after": selector}` or `{"before": selector}`.

A selector that matches nothing, or more than once without `occurrence`, is refused, naming the count
and the paragraphs that hold the text:

```
edit at index 0 (replace): "Preferred" matches 3 times in paragraph 8; give prefix or suffix
so it matches once, or occurrence (1 to 3, or "all")
```

### Writing content

New text is always `content`: a string, or an array of runs. In a string, `\t` becomes a tab and `\n`
a line break. Runs add formatting and richer content: tabs, breaks, fields, cross-references, notes,
links and pictures. See [Content](/developers/rest/edit-operations#content).

Any run can carry `format`, for example `{"text": "Name and title", "format": {"bold": true}}`. Plain
new text takes the formatting of the text before it, as typing does in Word.

For property objects such as `format`, a value sets the property, `false` turns it off even where the
style turns it on, `null` removes the direct setting so the style decides, and an omitted key leaves
it alone. To remove bold everywhere, send `false`; `null` would leave a heading that is bold through
its style bold.

## How a request is applied

Three rules govern the list of edits in one request:

1. **Addresses mean the uploaded document.** Every locator and selector refers to the document as you
   read it, whatever earlier edits in the request have done. If edit 1 inserts a paragraph after
   paragraph 5, edit 2's `{"index": 10}` still means the paragraph the listing gave index 10.
2. **Edits apply in request order.** When two edits set the same property of the same text, the later
   one wins. Content inserted at the same point appears in request order.
3. **An edit whose target an earlier edit removed or rewrote is refused**, and so is the whole
   request. For example, replacing text that an earlier edit already deleted.

Three consequences:

- **All or nothing.** If any edit is refused, or fails while being applied, nothing is applied and no
  file is produced. Every refusal in the request is reported together.
- **Undefined means refused.** A combination whose result the reference does not define is refused
  with an error that names it. Nothing is guessed.
- **Repeating an edit repeats its effect.** Sending an insertion twice inserts the content twice.

Decisions on existing tracked changes (`accept_revision`, `reject_revision`) go alone. A request that
contains one contains only decisions. To accept changes and then edit, send two requests: read the
listing of the first result's `files.clean.file_id`, and send the second against that `file_id`.

Every edit type, with its fields, examples and refusals, is in
[Edit operations](/developers/rest/edit-operations). The machine-readable definition of each is in
the API's OpenAPI specification under `components.schemas.EditRequest`.

### Examples

Indexes in these examples are illustrative. Use the ones your document's listing prints.

Fill in a signature block. Each value goes after its label's tab, so the underlined tab that draws
the line is untouched:

```json
[
  { "type": "replace", "paragraphs": { "index": 302 }, "text": { "match": "[Insert Company Name]" }, "content": "Version Story" },
  { "type": "insert", "paragraphs": { "index": 304 }, "at": { "after": { "match": "Name:\t" } }, "content": "Dana Reyes" },
  { "type": "insert", "paragraphs": { "index": 305 }, "at": { "after": { "match": "Title:\t" } }, "content": "General Counsel" }
]
```

Replace a defined term everywhere, and un-bold a whole document:

```json
[
  { "type": "replace", "paragraphs": "all", "text": { "match": "Acme Ltd", "occurrence": "all" }, "content": "Acme Holdings Ltd" },
  { "type": "format", "paragraphs": "all", "format": { "bold": false } }
]
```

Add a comment, then reply to it. The `ref` names the new thread:

```json
[
  { "type": "add_comment", "paragraphs": { "index": 25 }, "content": "double check on this", "ref": "c1" },
  { "type": "reply_comment", "comment": { "ref": "c1" }, "content": "will do" }
]
```

Move a clause and change its text in the same request. Index 14 still names the clause after the
move, and the two edits could be listed in either order:

```json
[
  { "type": "move_paragraphs", "paragraphs": { "index": 14 }, "destination": { "paragraph": { "index": 22 }, "position": "after" } },
  { "type": "replace", "paragraphs": { "index": 14 }, "text": { "match": "thirty (30) days" }, "content": "sixty (60) days" }
]
```

## Response

`POST /v1/edit` and `GET /v1/edit/{edit_id}` return the same shape. `files` holds the edit's two
files, each a [File object](/developers/rest/files), the same one `GET /v1/files/{file_id}` answers
with. Their `file_id`s are allocated when the edit is created, so they are present while processing
and after a failure.

| Entry | Contents |
| --- | --- |
| `files.clean` | The edited document, clean: the edits are accepted. To make further changes, send its `file_id` as the next edit's `file_id` |
| `files.comparison` | The redline: the document you edited compared with the edited one, every edit a Word tracked change attributed to `author` and `date` |

### GET /v1/edit/&#123;edit_id&#125;

Safe to call as often as you like: it creates nothing. It waits up to 5 seconds for the edit and
answers as soon as it finishes.

| Parameter | In | Required | Default | Meaning |
| --- | --- | --- | --- | --- |
| `edit_id` | path | Yes | — | The id from `POST /v1/edit` |
| `format` | query | No | `docx` | The formats of both files: `pdf`, `html`, `docx`, `md`, `json`, as `GET /v1/files/{file_id}` serves them. Repeatable or comma-separated |
| `inline` | query | No | `false` | `true` adds each file's bytes as `content_base64` to each ready download |

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

### Response: ready

```json
{
  "edit_id": "edt_MTp2cy0xOnZlLTE",
  "status": "ready",
  "files": {
    "clean": {
      "file_id": "8b41d7a0-...",
      "file_name": "nda (edited).docx",
      "status": "ready",
      "downloads": [
        { "format": "pdf", "url": "https://documents.versionstory.com/...", "file_name": "nda (edited).pdf", "expires_at": "2026-10-22T22:00:00Z" },
        { "format": "docx", "url": "https://documents.versionstory.com/...", "file_name": "nda (edited).docx", "expires_at": "2026-10-22T22:00:00Z" }
      ]
    },
    "comparison": {
      "file_id": "d90c5e12-...",
      "file_name": "Redline.docx",
      "status": "ready",
      "downloads": [
        { "format": "pdf", "url": "https://documents.versionstory.com/...", "file_name": "Redline.pdf", "expires_at": "2026-10-22T22:00:00Z" },
        { "format": "docx", "url": "https://documents.versionstory.com/...", "file_name": "Redline.docx", "expires_at": "2026-10-22T22:00:00Z" }
      ]
    }
  }
}
```

`status` is the edit's own, and each file's `status` is that file's: a `ready` edit with a PDF still
converting has that file `processing`, with `Retry-After: 5`. Each `url` is signed, needs no
`Authorization` header, and stops working at `expires_at`, one hour after it was issued. Poll again
for fresh ones.

### Response: failed

Terminal: `"status": "failed"` with an `error`. Submit a corrected request to retry. With
`"wait": true`, an edit that fails while being applied answers HTTP `200` with this body, so check
`status`, not only the HTTP code.

| `error.code` | Meaning |
| --- | --- |
| `INVALID_EDIT` | The edit could not be applied as written, for instance one whose target an earlier edit removed. `error.message` names it as `edit at index N (type): ...`, so you can correct it and send the request again |
| `CARRY_CONFLICT` | An amend was not made because a later version changed the same text. `error.message` quotes each version's conflicting passage. No version changed |
| `EDIT_FAILED` | Anything else. Also returned if the edited document is missing after the edit otherwise succeeded |

Nothing is applied when an edit fails.

### Versions

Through this API only `POST /v1/documents` creates a [Document](/developers/rest/documents); an edit
never does. An edit of a file that is a version of one of your Documents (a version's own file, or an
earlier kept edit's `files.clean.file_id`) is kept as that Document's next version, where your account
keeps version history (an organization with zero-day retention never does). The response then carries
`version`, a reference to it, and `app_url`:

```json
"version": { "document_id": "...", "version_id": "...", "index": 1, "label": "v2" },
"app_url": "https://app.versionstory.com/documents/.../versions/..."
```

`index` is the version's position among the Document's versions, counted from 0, and `label` is the
label the app shows. A request names a version by `{"version_id": "..."}`. `app_url` opens the version
at its redline in the Version Story app. A response without `version` is a one-off edit: its file is
not a version of one of your Documents, such as an upload, or history is not kept. To keep an upload's
history, make it a Document's first version with `POST /v1/documents` and its `file_id`, then edit
that file.

- **A version's file.** The edit is that version's next version. A further edit of the version your
  own edit made, while it is still the Document's latest version, amends that version instead, so one
  version holds all of your changes for the same piece of work. Send `"new_version": true` for a
  separate version, such as a draft for someone else.
- **`"amend": true`.** The change goes into the named version wherever it is in the history. See
  [Amending a version](#amending-a-version).

`version_name` names the version and its file; without it, a new version keeps the name of the version
it was made from, and an amended version keeps its own. A one-off edit's `version_name` names only its
file, and its `description` is ignored.

### Amending a version

`"amend": true` puts the change in the version whose file `file_id` names, wherever it is in the
history, and carries it into every version after it: each later version gets a new file with the
change and a redline redone against the version before it. Nothing changes until every later
version's new file is made without a conflict; the edit stays `processing` until then. A ready
response lists a reference to each of those versions in `carried_versions`;
[`GET /v1/documents/{document_id}`](/developers/rest/documents#get-v1documentsdocumentid) reads each
with its new file.

An amend is refused before anything is created with `400 INVALID_REQUEST` when:

- `new_version` is also `true`;
- the account does not keep version history;
- no version holds the file;
- more than 15 versions follow it, a later version merges others, or a later version has no redline
  against the version before it.

It answers `409 CONFLICT` while a later version's redline is still being made; send it again shortly.
If a later version changed the same text, the edit fails with `CARRY_CONFLICT` and no version is
changed.

### POST /v1/edit/&#123;edit_id&#125;/acknowledge

For an organization with [zero-day retention](/developers/reference/data-handling#retention), deletes
the edit's two files as soon as you have saved what you need, instead of an hour after the edit
finished. No body; the response is `202` with `"status": "deletion_queued"`. The file you edited is
kept until its own deadline, so you can edit it again; acknowledge it with
[`POST /v1/files/{file_id}/acknowledge`](/developers/rest/files#post-v1filesfileidacknowledge) once no
further edit needs it.

## Errors

| Status | Code | Cause |
| --- | --- | --- |
| 400 | `INVALID_REQUEST` | The request itself is malformed: a missing `file_id`, a field of the wrong type, `outputs` or `inline` without `wait`, both `new_version` and `amend`, an `author` or `date` that does not meet the rules above, or an amend refused as [above](#amending-a-version) |
| 400 | `INVALID_EDIT` | An edit was refused before anything was created: a stale `schema_version`, a selector that matched nothing or too much, or a combination that is undefined. Every refusal in the request is reported together, each as `edit at index N (type): ...` |
| 400 | `UNSUPPORTED_FILE_TYPE` | The file is a type that is never prepared for editing, such as a workbook |
| 400 | `UNSUPPORTED_FORMAT` | A `format` is unknown |
| 400 | `INVALID_EDIT_ID` | The `edit_id` is malformed |
| 401 | `INVALID_API_KEY` | Missing, malformed, unknown, revoked, or expired key |
| 404 | `EDIT_NOT_FOUND` | No such edit or file, or it belongs to another organization |
| 409 | `CONFLICT` | The document is still being prepared after the wait; retry after `Retry-After`. For an amend, a later version's redline is still being made |
| 410 | `CONTENT_DELETED` | The file's content was removed by the organization's retention setting |

Validation runs before anything is created, so a refused request costs nothing.

## The whole loop, in Python

```python
import time
import requests

BASE = "https://api.versionstory.com"
headers = {"Authorization": "Bearer vs_live_..."}


def download(file_id, file_format):
    """One format of a file, once it is ready."""
    while True:
        response = requests.get(f"{BASE}/v1/files/{file_id}", headers=headers, params={"format": file_format})
        response.raise_for_status()
        body = response.json()
        if body["status"] == "failed":
            raise RuntimeError(f"{file_format} failed: {body.get('error') or body.get('failed_formats')}")
        if body["status"] == "ready":
            content = requests.get(body["downloads"][0]["url"])
            content.raise_for_status()
            return content.content
        time.sleep(int(response.headers.get("Retry-After", "1")))


with open("nda.docx", "rb") as document:
    uploaded = requests.post(f"{BASE}/v1/files", headers=headers, files={"document": document})
uploaded.raise_for_status()
file_id = uploaded.json()["file_id"]

listing = download(file_id, "md").decode()
# Find the line holding "[Company Name]" and copy its <p index="n"/>.

edit = requests.post(
    f"{BASE}/v1/edit",
    headers=headers,
    json={
        "file_id": file_id,
        "schema_version": 1,
        "author": "Dana Reyes",
        "edits": [
            {
                "type": "replace",
                "paragraphs": {"index": 7},
                "text": {"match": "[Company Name]"},
                "content": "Acme, Inc.",
            }
        ],
    },
)
edit.raise_for_status()
edit_id = edit.json()["edit_id"]

while True:
    status = requests.get(f"{BASE}/v1/edit/{edit_id}", headers=headers, params={"format": "pdf,docx"})
    status.raise_for_status()
    body = status.json()
    if body["status"] == "failed":
        raise RuntimeError(f"Edit failed: {body['error']['code']} {body['error'].get('message', '')}")
    files = body["files"]
    if body["status"] == "ready" and all(f["status"] == "ready" for f in files.values()):
        break
    time.sleep(int(status.headers.get("Retry-After", "1")))

for name in ("clean", "comparison"):
    for item in files[name]["downloads"]:
        content = requests.get(item["url"])
        content.raise_for_status()
        with open(item["file_name"], "wb") as out:
            out.write(content.content)

# To edit again, read the edited document's listing and send the next request with its file_id.
edited_listing = download(files["clean"]["file_id"], "md").decode()
```

## Related

- [Edit operations](/developers/rest/edit-operations): every edit type, in full
- [Files](/developers/rest/files): uploads, downloads and clean metadata
- [Errors](/developers/reference/errors): every error code and what to do about it

---

<!-- https://www.versionstory.com/developers/rest/create -->

# Create

> Write a new Word document from the same edits POST /v1/edit takes, applied to a blank page, optionally in your own house style.

> Integrating Version Story into a codebase as a coding agent? Follow https://www.versionstory.com/developers/quickstart.md: it signs the engineer in, gets an API key, and puts the PDF redline in their app first.

`POST /v1/create` writes a new `.docx` by applying [edits](/developers/rest/edit-operations) to a
blank document: headings and lists, formatted text, tables, headers and footers, comments, a table of
contents. It is a one-off job: it needs no source document and makes no comparison. To change an
existing document, use [Edit](/developers/rest/edit) instead.

| | |
| --- | --- |
| Base URL | `https://api.versionstory.com` |
| Auth | `Authorization: Bearer vs_...`. See [Authentication](/developers/authentication) |

The created document is a [file](/developers/rest/files) like an upload, so it is served as a PDF,
Word, HTML and its listing, and it can be edited with `POST /v1/edit` by its `file_id`.

## The flow

1. `POST /v1/create` with the file name and the edits. Returns `202` and a `creation_id`, or with
   `wait`, the finished document.
2. `GET /v1/create/{creation_id}` until `status` is `ready`, then download the document from
   `file.downloads[].url`.

## POST /v1/create

A JSON body, or `multipart/form-data` when it uploads `style_template` or `pictures`, with `edits`
and `outputs` as JSON strings.

| Field | Required | Meaning |
| --- | --- | --- |
| `name` | Yes | The output file name, ending in `.docx` |
| `edits` | Yes | The edits that write the document, applied in order to a blank document, at most 200. The same edits, schema and refusals as [`POST /v1/edit`](/developers/rest/edit-operations) |
| `author` | No | The name new comments carry when an edit names none |
| `date` | No | ISO 8601 date and time on new comments. Default: the time of the request |
| `wait` | No | `true` holds the response until the document is written, up to 5 seconds |
| `inline` | No | With `wait`: `true` adds the file's bytes as `content_base64` to each ready download |
| `outputs` | No | With `wait`: the formats to return, as `GET /v1/create/{creation_id}`'s `format` takes them. Default `["docx"]` |
| `style_template` | No | Multipart only: a `.docx`, `.dotx` or `.dotm` whose styles, numbering, and theme fonts and colors the document uses. Its content, macros, headers and footers, and page layout are not copied |
| `pictures` | No | Multipart only, repeated: PNG, JPEG or GIF files that picture runs name by file name. See [Pictures](/developers/rest/edit-operations#pictures) |

A blank document has no paragraph to name, so the first content goes at the body's start or end:
`"destination": {"position": "end"}` (see [Destinations](/developers/rest/edit-operations#destinations)).
Give what a later edit names a `ref` and name it by that ref.

```json
{
  "name": "Report.docx",
  "edits": [
    { "type": "insert_paragraphs", "destination": { "position": "end" },
      "new_paragraphs": [
        { "content": "Quarterly report", "style_id": "Heading1" },
        { "content": [{ "text": "Prepared for: ", "format": { "bold": true } }, { "text": "Example Co." }], "ref": "intro" }
      ] },
    { "type": "insert_paragraphs", "destination": { "position": "end" },
      "list": { "new": "bulleted" }, "new_paragraphs": [{ "content": "Revenue grew" }, { "content": "Costs fell" }] },
    { "type": "insert_table", "destination": { "position": "end" }, "header_row": true,
      "rows": [["Region", "Q1", "Q2"], ["West", "10", "20"],
               [{ "new_paragraphs": [{ "content": "Total" }], "span": 2 }, "30"]] },
    { "type": "add_comment", "paragraphs": { "ref": "intro" }, "content": "Confirm the name" },
    { "type": "add_header_footer", "part": { "kind": "footer", "type": "default", "section": { "index": 0 } },
      "new_paragraphs": [{ "content": [{ "text": "Page " }, { "type": "field", "code": "PAGE" }],
                           "paragraph_format": { "alignment": "center" } }] }
  ]
}
```

With a house style, as multipart:

```bash
curl -sS https://api.versionstory.com/v1/create \
  -H "Authorization: Bearer vs_live_..." \
  -F "name=Outline.docx" \
  --form-string 'edits=[{"type":"insert_paragraphs","destination":{"position":"end"},"new_paragraphs":[{"content":"Webinar outline","style_id":"Heading1"},{"content":"Introduction"}]}]' \
  -F "style_template=@house-style.dotx"
```

```json
{
  "creation_id": "crt_...",
  "status": "processing",
  "file": { "file_id": "5b7c...", "file_name": "Outline.docx", "status": "processing", "pending_formats": ["docx"] }
}
```

Without a template, the styles are `Normal`, `Heading1`, `Heading2`, `Heading3` and `Heading4`, on a
US Letter page with one-inch margins. A template's styles replace those with the same id and add the
rest. An edit the job cannot apply as written, such as one naming an unknown style, fails the creation
with `INVALID_EDIT` and the reason.

Each creation counts as one document against your usage. Sending the `POST` again starts another
creation, so keep the `creation_id` and poll it instead.

## GET /v1/create/&#123;creation_id&#125;

Read-only and safe to poll; it answers at once.

| Parameter | In | Required | Default | Meaning |
| --- | --- | --- | --- | --- |
| `creation_id` | path | Yes | — | The id from `POST /v1/create` |
| `format` | query | No | `docx` | The created file's formats: `pdf`, `html`, `docx`, `md`, `json`, as `GET /v1/files/{file_id}` serves them. Repeatable or comma-separated |
| `inline` | query | No | `false` | `true` adds the file's bytes as `content_base64` to each ready download |

```json
{
  "creation_id": "crt_...",
  "status": "ready",
  "file": {
    "file_id": "5b7c...",
    "file_name": "Outline.docx",
    "status": "ready",
    "downloads": [
      { "format": "docx", "url": "https://documents.versionstory.com/...", "file_name": "Outline.docx", "expires_at": "2026-10-22T18:00:00Z" }
    ]
  }
}
```

`status` is the creation's: `processing` until the Word document is written, `ready` once it is, and
`failed` when the job failed, with `error` `{"code": "INVALID_EDIT", "message": "edit at index N (type): …"}`
when an edit could not be applied as written, or `{"code": "CREATION_FAILED"}` otherwise. `file.status`
is the file's: a PDF, HTML or listing is made from the Word document after it is written, so a `ready`
creation can have a `processing` file. Either one still processing returns `Retry-After: 5`. The
document is clean, with no tracked changes. Links are valid for one hour; poll again for fresh ones.

## POST /v1/create/&#123;creation_id&#125;/acknowledge

For an organization with [zero-day retention](/developers/reference/data-handling#retention), deletes
the created document as soon as you have saved it, instead of an hour after it was made. No body; the
response is `202` with `"status": "deletion_queued"`.

## Errors

| Status | Code | Cause |
| --- | --- | --- |
| 400 | `INVALID_REQUEST` | A missing `name` or `edits`, JSON that does not parse, or `inline` or `outputs` without `wait` |
| 400 | `INVALID_EDIT` | An edit was refused, every refusal listed and naming its edit as `edit at index N (type): …` |
| 400 | `UNSUPPORTED_FILE_TYPE` | `name` does not end in `.docx`, or `style_template` is not a `.docx`, `.dotx` or `.dotm` |
| 400 | `INVALID_CREATION_ID` | The `creation_id` is malformed |
| 401 | `INVALID_API_KEY` | Missing, malformed, unknown, revoked, or expired key |
| 402 | `USAGE_LIMIT_REACHED` | The organization's monthly upload allowance is exhausted |
| 404 | `CREATION_NOT_FOUND` | No such creation, or it belongs to another organization |
| 409 | `CONFLICT` | An acknowledgment of a creation that is still processing, or that has no zero-day retention |
| 410 | `CONTENT_DELETED` | The document was deleted under zero-day retention |
| 413 | `FILE_TOO_LARGE` | `style_template` exceeds the 100 MB limit |

## Related

- [Edit operations](/developers/rest/edit-operations): every edit a creation can apply
- [Edit](/developers/rest/edit): change the created document with tracked changes, by its `file_id`
- [Data handling](/developers/reference/data-handling): what is kept, and for how long

---

<!-- https://www.versionstory.com/developers/rest/merge -->

# Merge

> Combine several separately edited revisions of one document into a single draft whose tracked changes are labeled by their source.

> Integrating Version Story into a codebase as a coding agent? Follow https://www.versionstory.com/developers/quickstart.md: it signs the engineer in, gets an API key, and puts the PDF redline in their app first.

Two endpoints, the same shape as [Compare](/developers/rest/compare): one submits a base
document and its revisions, the other polls for the merged draft.

| | |
| --- | --- |
| Base URL | `https://api.versionstory.com` |
| Auth | `Authorization: Bearer vs_...`. See [Authentication](/developers/authentication) |

## What a merge is for

Three people take the same contract and each edits their own copy. You now have one original
and three revisions, and no single document holding everybody's work.

A merge produces that document: one Word draft in which every change from every revision is
a tracked change, labeled with the revision it came from. Reviewing it is the ordinary Word
workflow — accept and reject, with attribution intact.

This is a different job from comparing. A comparison answers *what changed between these
two*. A merge answers *what does everyone's work look like together*.

## The flow

1. Upload the base and each revision with [`POST /v1/files`](/developers/rest/files#post-v1files).
   Each upload returns a `file_id`.
2. `POST /v1/merge` with a JSON body naming the base and its revisions by those ids, each revision
   with its author. Returns `202` and a `merge_id`.
3. `GET /v1/merge/{merge_id}?format=pdf` until `status` is `ready`, then fetch each
   `file.downloads[].url`.

A merge takes longer than a comparison, and for a structural reason worth knowing: each
revision is first compared against the base, and the merge itself begins only once all of
those redlines exist. Expect it to sit in `processing` for longer than a single comparison
would, with the gap widening as you add revisions.

## POST /v1/merge

A JSON body (`Content-Type: application/json`) naming documents already in Version Story.

| Field | Required | Meaning |
| --- | --- | --- |
| `original` | Yes | The base document every revision was edited from |
| `revisions` | Yes, two to ten | The revised copies of the base, in any order |
| `revisions[].author` | No | The name that revision's tracked changes are credited to. Default: the revision's file name |
| `include_cover_sheet` | No | Whether the merged document opens with the cover sheet explaining how to review it. Default `true` |
| `include_changes_summary` | No | Whether the merged document ends with the summary of each revision's changes. Default `true` |

`original` and each revision name one document, in one of two forms:

| Form | Names |
| --- | --- |
| `{"file_id": "..."}` | A file you uploaded with `POST /v1/files`, or an edit's `files.clean` |
| `{"document_id": "...", "version_id": "..."}` | A version of one of your [Documents](/developers/rest/documents). `version_id` may be `"latest"` |

Upload each document once, then merge:

```bash
curl -sS https://api.versionstory.com/v1/files \
  -H "Authorization: Bearer vs_live_..." \
  -F "document=@nda.docx"
# {"file_id": "3f2a9c1e-...", "status": "processing"}

curl -sS https://api.versionstory.com/v1/files \
  -H "Authorization: Bearer vs_live_..." \
  -F "document=@nda_from_counsel.docx"
# {"file_id": "8b41d7a0-...", "status": "processing"}

curl -sS https://api.versionstory.com/v1/files \
  -H "Authorization: Bearer vs_live_..." \
  -F "document=@nda_from_finance.docx"
# {"file_id": "d90c5e12-...", "status": "processing"}

curl -sS https://api.versionstory.com/v1/merge \
  -H "Authorization: Bearer vs_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "original": {"file_id": "3f2a9c1e-..."},
    "revisions": [
      {"file_id": "8b41d7a0-...", "author": "Dana Reyes"},
      {"file_id": "d90c5e12-...", "author": "Sam Patel"}
    ]
  }'
```

### Attributing the changes

Each revision's `author` labels the tracked changes from that revision. In the merged document,
they read `Dana Reyes — nda_from_counsel.docx`: the name, then the revision's file name, so
reviewers can tell whose edit each one is. Without `author`, a revision's changes are labeled with
its file name alone, so pass the names your reviewers should see.

### How conflicts show

Each revision is compared against the same base, so the result does not depend on their order.
When two revisions change the same words differently, the merged draft deletes the original words
once, credited to both revisions, and inserts each revision's replacement beside it under its own
author. The reviewer keeps one replacement and rejects the other.

The PDF marks each conflict in the margin. The [JSON](/developers/reference/json-format#segments)
wraps it in a `conflict` segment, so code can list the conflicts.

### Response — 202 Accepted

```json
{ "merge_id": "mrg_MTp2cy0xOnZ2LTE", "status": "processing" }
```

`merge_id` is opaque and stable, and is not interchangeable with a `comparison_id` — each
endpoint rejects the other's ids.

### Notes on inputs

- Every revision must be a revision *of the base*. Folding together drafts that share no base
  is a different operation, combine, which the REST API does not offer.
- A merge takes from two to ten revisions. With a single revision there is nothing to
  reconcile: the comparison redline already is the combined document, so use
  [`POST /v1/compare`](/developers/rest/compare).
- Sources may be `.docx`, `.doc`, or `.pdf`, up to **100 MB** each. Non-`.docx` inputs are
  converted first, so they spend longer in `processing`. Presentations and spreadsheets
  cannot be merged.
- Revisions routinely share a filename — three copies of `nda.docx` is the normal case.
  Duplicates are disambiguated automatically.
- A redline PDF is read as its upload chose: send `track_redline_changes` with
  `POST /v1/files`. See [Redline PDFs](/developers/reference/formats-and-limits#redline-pdfs).

### Errors

| Status | Code | Cause |
| --- | --- | --- |
| 400 | `INVALID_REQUEST` | Fewer than two or more than ten revisions, a document named in neither form or in both, or an unknown field |
| 400 | `INVALID_FILE_ID`, `INVALID_DOCUMENT_ID`, `INVALID_VERSION_ID` | An id is malformed |
| 400 | `UNSUPPORTED_FILE_TYPE` | A document is not a `.docx`, `.doc`, or `.pdf` |
| 400 | `DOCUMENT_UNREADABLE` | A `.docx` source is not a readable Word file: corrupt, or encrypted |
| 400 | `TRIAL_BATCH_TOO_LARGE` | An [API trial](/developers/reference/formats-and-limits#api-trials) organization sent more revisions than its per-minute allowance |
| 401 | `INVALID_API_KEY` | Missing, malformed, unknown, revoked, or expired key |
| 402 | `USAGE_LIMIT_REACHED` | The organization's monthly upload allowance is exhausted |
| 403 | `API_KEY_ORG_MISMATCH` | The key's service account no longer belongs to the key's organization |
| 403 | `TRIAL_COMPARISON_LIMIT_REACHED` | The organization's API trial allowance is used up |
| 403 | `ZERO_DAY_RETENTION_UNSUPPORTED` | A Document's version was named by an organization with [zero-day retention](/developers/reference/data-handling#retention), which keeps no versions |
| 404 | `FILE_NOT_FOUND` | No such file, it belongs to another organization, or it is a redline or a merged draft rather than a document |
| 404 | `DOCUMENT_NOT_FOUND`, `VERSION_NOT_FOUND` | No such Document or version, or it belongs to another organization |
| 413 | `FILE_TOO_LARGE` | A file exceeds the 100 MB limit |
| 429 | `TRIAL_RATE_LIMITED` | The trial's per-minute rate is exceeded; wait for `Retry-After` seconds |

Each revision is compared against the base on the way to the merge, so on an API trial a
merge uses one comparison of the allowance per revision.

### Multipart upload (deprecated)

`POST /v1/merge` also takes the documents' bytes as `multipart/form-data`, with the authors in a
separate positional list. It is **deprecated**: new code uploads each document with
`POST /v1/files` and sends the [JSON body](#post-v1merge), where each revision carries its own
`author`. Existing clients keep working.

| Field | Required | Meaning |
| --- | --- | --- |
| `original` | Yes | The base document every revision was edited from |
| `revisions` | Yes, two to ten times | A revised copy of the base — repeat the field per revision |
| `revision_authors` | No | Name to attribute a revision's tracked changes to — repeat once per revision, in the same order as `revisions` |
| `include_cover_sheet` | No | As in the JSON body. Default `true` |
| `include_changes_summary` | No | As in the JSON body. Default `true` |
| `track_redline_changes` | No | `true` turns a redline PDF's insertions and deletions into tracked changes, so the merge reads it as its revised text; see [Redline PDFs](/developers/reference/formats-and-limits#redline-pdfs). Default `true` |

```bash
curl -sS https://api.versionstory.com/v1/merge \
  -H "Authorization: Bearer vs_live_..." \
  -F "original=@nda.docx" \
  -F "revisions=@nda_from_counsel.docx" \
  -F "revisions=@nda_from_finance.docx" \
  -F "revision_authors=Dana Reyes" \
  -F "revision_authors=Sam Patel"
```

Each `revision_authors` value labels the revision in the same position: the first name goes with
the first `revisions` file, and so on. Send exactly one value per revision; a different count is
`400 INVALID_REQUEST`. Values are not comma-split: `Dana Reyes,Sam Patel` is one name. An empty or
whitespace-only value keeps that revision's default label, its file name.

The response and errors are the JSON body's, with `INVALID_REQUEST` also covering a file field
that is missing or empty.

## GET /v1/merge/&#123;merge_id&#125;

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

| Parameter | In | Required | Default | Meaning |
| --- | --- | --- | --- | --- |
| `merge_id` | path | Yes | — | The id from `POST /v1/merge` |
| `format` | query | No | `docx` | Which renderings to return. Name `pdf` to show the merged document |

### Formats

As with compare, `format` is repeatable and also accepts a comma-separated list. A request that
names no format gets the Word file.

| `format` | Artifact | Use it for |
| --- | --- | --- |
| `pdf` | PDF rendering of the merged document, with conflicts drawn in the margin | Showing a person the merged document. **Start here** |
| `docx` | The merged Word document, with every revision's changes tracked and labeled (the default) | A lawyer to resolve the changes in Word and send on |
| `md` | The merged document as Markdown, with every change marked inline and its revision named. See [Redline format](/developers/reference/redline-format) | A model to read |
| `json` | The merged document as structured data, conflicts included. See [Document JSON](/developers/reference/json-format) | Code that acts on the changes, such as listing the conflicts. Not for display |

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

### Response: ready

`file` is the merged document as a [file](/developers/rest/files), with one download per requested
format.

```json
{
  "merge_id": "mrg_MTp2cy0xOnZ2LTE",
  "status": "ready",
  "file": {
    "file_id": "7d1a...",
    "file_name": "Merged nda.docx",
    "status": "ready",
    "downloads": [
      {
        "format": "pdf",
        "url": "https://documents.versionstory.com/...",
        "file_name": "Merged nda.pdf",
        "expires_at": "2026-10-22T22:00:00Z"
      },
      {
        "format": "docx",
        "url": "https://documents.versionstory.com/...",
        "file_name": "Merged nda.docx",
        "expires_at": "2026-10-22T22:00:00Z"
      }
    ]
  }
}
```

Each `url` is signed and expires one hour after it was issued, needs no `Authorization` header, and
can be reissued by polling again. `status` is the merge's, and `file.status` the merged file's.

### Response: still generating

Returned with `Retry-After: 1`. The PDF and Markdown trail the Word document, so formats that are
ready are returned at once and the rest are named in `file.pending_formats`. `status` is `ready`
only once every requested format exists.

```json
{
  "merge_id": "mrg_MTp2cy0xOnZ2LTE",
  "status": "processing",
  "file": {
    "file_id": "7d1a...",
    "file_name": "Merged nda.docx",
    "status": "processing",
    "downloads": [],
    "pending_formats": ["pdf", "docx"]
  }
}
```

### Response: failed

Terminal, and reported as `200 OK` because the request itself succeeded: it correctly told you the
merge is not going to happen. Submit a new merge to retry.

```json
{
  "merge_id": "mrg_MTp2cy0xOnZ2LTE",
  "status": "failed",
  "error": {
    "code": "DOCUMENT_CONVERSION_FAILED",
    "upstream_code": "ONE_OR_MORE_DOCUMENTS_FAILED_TO_CONVERT_TO_DOCX"
  }
}
```

A merge fails if any of its underlying comparisons fails, since the merge is assembled from
them. The `error.code` values are the same set as
[Compare](/developers/rest/compare#response-failed), plus `MERGE_UNUSABLE` when the merge's
records can no longer produce a document. A failed merge has no `file`, except when only its
PDF failed: then it reports `failed` with an `error`, and `file` carries the other requested
formats with `failed_formats: ["pdf"]`.

### Request errors

| Status | Code | Cause |
| --- | --- | --- |
| 400 | `INVALID_MERGE_ID` | The id is malformed, or is a `cmp_` comparison id |
| 400 | `UNSUPPORTED_FORMAT` | A requested `format` is not `docx`, `pdf`, `md`, or `json` |
| 403 | `TRIAL_WORD_DOWNLOADS_EXCLUDED` | `docx` was requested, and the organization's API trial excludes Word downloads |
| 404 | `MERGE_NOT_FOUND` | No such merge, or it belongs to another organization |

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

## POST /v1/merge/&#123;merge_id&#125;/acknowledge

For an organization with [zero-day retention](/developers/reference/data-handling#retention),
deletes the merge as soon as you have saved what you need, instead of an hour after it
finished: the base and revisions, each revision's redline against the base, and the merged
document. No body.

```bash
curl -sS -X POST https://api.versionstory.com/v1/merge/mrg_MTp2cy0xOnZ2LTE/acknowledge \
  -H "Authorization: Bearer vs_live_..."
```

```json
{ "merge_id": "mrg_MTp2cy0xOnZ2LTE", "status": "deletion_queued" }
```

A merge that is still processing, or that has no zero-day retention, is `409 CONFLICT`.

## The whole loop, in Python

```python
import time
import requests

BASE = "https://api.versionstory.com"
headers = {"Authorization": "Bearer vs_live_..."}


def upload(path):
    """Store one document and return its file_id."""
    with open(path, "rb") as document:
        response = requests.post(f"{BASE}/v1/files", headers=headers, files={"document": document})
    response.raise_for_status()
    return response.json()["file_id"]


merge = {
    "original": {"file_id": upload("nda.docx")},
    "revisions": [
        {"file_id": upload("nda_from_counsel.docx"), "author": "Dana Reyes"},
        {"file_id": upload("nda_from_finance.docx"), "author": "Sam Patel"},
    ],
}
created = requests.post(f"{BASE}/v1/merge", headers=headers, json=merge)
created.raise_for_status()
merge_id = created.json()["merge_id"]

while True:
    status = requests.get(
        f"{BASE}/v1/merge/{merge_id}", headers=headers, params={"format": "pdf,docx"}
    )
    status.raise_for_status()
    body = status.json()
    if body["status"] == "ready":
        break
    if body["status"] == "failed":
        raise RuntimeError(f"Merge failed: {body['error']['code']}")
    time.sleep(int(status.headers.get("Retry-After", 1)))

for download in body["file"]["downloads"]:
    content = requests.get(download["url"])
    content.raise_for_status()
    with open(download["file_name"], "wb") as out:
        out.write(content.content)
```

## Related

- [Compare](/developers/rest/compare): two documents, one redline
- [Files](/developers/rest/files): uploading the documents a merge names
- [Documents](/developers/rest/documents): versions a merge can name by `document_id` and `version_id`
- [Redline format](/developers/reference/redline-format): how the Markdown marks and attributes
  each change

---

<!-- https://www.versionstory.com/developers/rest/documents -->

# Documents

> Keep a document's versions in one history, with a redline between each version and the one before it, and follow a paragraph, table, row or cell from one version to another.

> Integrating Version Story into a codebase as a coding agent? Follow https://www.versionstory.com/developers/quickstart.md: it signs the engineer in, gets an API key, and puts the PDF redline in their app first.

A Document is a named series of versions. Each version is an immutable file, and every version after
the first carries a redline against the version it was made from. Every redline also keeps a map of
what each paragraph, table, row and cell became, so you can follow an element from one version to
any other.

| | |
| --- | --- |
| Base URL | `https://api.versionstory.com` |
| Auth | `Authorization: Bearer vs_...`. See [Authentication](/developers/authentication) |

A Document keeps its versions until you delete them, so an organization with
[zero-day retention](/developers/reference/data-handling#retention) cannot use Documents: every
`/v1/documents` route answers `403 ZERO_DAY_RETENTION_UNSUPPORTED`. The API reaches only the
Documents your API keys created; one a person made in the web app answers as missing.

## The flow

1. `POST /v1/documents` with the first version's file. Returns `201` and the Document, holding its
   one version.
2. `POST /v1/documents/{document_id}/versions` with each later version. Returns `202` and the
   version, whose redline against the version before it is being made.
3. `GET /v1/documents/{document_id}` for every version, or
   `GET /v1/documents/{document_id}/versions/{version_id}` for one, with its file and its redline in
   the formats you ask for.
4. `POST /v1/documents/{document_id}/elements/lookup` to follow paragraphs, tables, rows or cells
   across the versions.

| Method | Path | Does |
| --- | --- | --- |
| `POST` | `/v1/documents` | Creates a Document from its first version |
| `GET` | `/v1/documents` | Lists your Documents, newest first |
| `GET` | `/v1/documents/{document_id}` | A Document and every version |
| `POST` | `/v1/documents/{document_id}/versions` | Adds a version |
| `GET` | `/v1/documents/{document_id}/versions/{version_id}` | One version, with its file and its redline |
| `POST` | `/v1/documents/{document_id}/elements/lookup` | Follows elements across versions |

## Naming a version

- **In a path**, a version is its `version_id`, or `latest` for the newest.
- **In a body**, a version is a locator, `{"version_id": "..."}`, where `"latest"` also names the
  newest. A version reference from a response, sent back as it is, works too: its `document_id` must
  be the Document the request names, and its `index` and `label` are ignored.
- **Never by `index`.** A version's `index` is its position, counted from 0, and it moves when an
  earlier version is deleted.

## POST /v1/documents

Creates a Document whose first version is an uploaded file, as `multipart/form-data`, or a file you
already hold, by `file_id`. Send exactly one of `file` and `file_id`.

| Field | Required | Meaning |
| --- | --- | --- |
| `file` | One of the two | The document, uploaded: a `.docx`, `.doc`, `.pdf`, `.eml` or `.msg`, up to 100 MB |
| `file_id` | One of the two | A file you uploaded with `POST /v1/files`, or an edit's `files.clean.file_id`. See [A file you already hold](#a-file-you-already-hold) |
| `name` | No | The Document's name. Default: the file's name |
| `track_redline_changes` | No | With `file`: whether a redline PDF's insertions and deletions become tracked changes. See [Redline PDFs](/developers/reference/formats-and-limits#redline-pdfs). Default `true` |

`file_id` and `name` also go as a JSON body: `{"file_id": "...", "name": "..."}`.

```bash
curl -sS https://api.versionstory.com/v1/documents \
  -H "Authorization: Bearer vs_live_..." \
  -F "file=@msa_v1.docx" \
  -F "name=Master Services Agreement"
```

The response is the Document as [`GET /v1/documents/{document_id}`](#get-v1documentsdocumentid)
answers it, with its one version in `versions`. A version made from an upload answers with its file
pending in `docx`, as it is still being prepared.

```json
{
  "document_id": "5f1c2a90-...",
  "name": "Master Services Agreement",
  "version_count": 1,
  "latest_version": { "document_id": "5f1c2a90-...", "version_id": "1c0f6a3e-...", "index": 0, "label": "v1" },
  "created_at": "2026-10-26T15:02:11Z",
  "updated_at": "2026-10-26T15:02:11Z",
  "metadata": {},
  "versions": [
    {
      "document_id": "5f1c2a90-...",
      "version_id": "1c0f6a3e-...",
      "index": 0,
      "label": "v1",
      "name": "msa_v1.docx",
      "files": {
        "clean": { "file_id": "3f2a9c1e-...", "file_name": "msa_v1.docx", "status": "processing", "pending_formats": ["docx"] }
      },
      "parents": [],
      "created_at": "2026-10-26T15:02:11Z",
      "metadata": {}
    }
  ]
}
```

## POST /v1/documents/&#123;document_id&#125;/versions

Adds a version after `parent`, the latest version by default. Its redline compares it with
`parent`. Naming an older version as `parent` starts a parallel draft beside the versions that
already follow it.

| Field | Required | Meaning |
| --- | --- | --- |
| `file` | One of the two | The new version's document, uploaded, as for `POST /v1/documents` |
| `file_id` | One of the two | A file you already hold. See [A file you already hold](#a-file-you-already-hold) |
| `parent` | No | The version it was made from, as a locator: `{"version_id": "..."}`. In a multipart request, a form field holding that JSON. Default: the latest version |
| `author_name` | No | Who the redline credits this version's changes to, up to 100 characters. Default: your organization's API service account |
| `description` | No | What changed in this version, shown with it in the app and returned as `description` |
| `track_redline_changes` | No | With `file`, as for `POST /v1/documents`. Default `true` |

A JSON body names a held file the same way: `{"file_id", "parent"?, "author_name"?, "description"?}`.

```bash
curl -sS https://api.versionstory.com/v1/documents/5f1c2a90-.../versions \
  -H "Authorization: Bearer vs_live_..." \
  -F "file=@msa_v2.docx" \
  -F "author_name=Dana Reyes" \
  -F "description=Shorter term"
```

The response, `202`, is the [Version object](#the-version-object). A version made from an upload
answers with its file and its redline pending in `docx`; poll
[`GET .../versions/{version_id}`](#get-v1documentsdocumentidversionsversionid) until they are ready.

```json
{
  "document_id": "5f1c2a90-...",
  "version_id": "7b2d9e41-...",
  "index": 1,
  "label": "v2",
  "name": "msa_v2.docx",
  "files": {
    "clean": { "file_id": "8b41d7a0-...", "file_name": "msa_v2.docx", "status": "processing", "pending_formats": ["docx"] },
    "comparison": { "file_id": "d90c5e12-...", "file_name": "Redline - msa_v1 vs msa_v2.docx", "status": "processing", "pending_formats": ["docx"] }
  },
  "parents": [{ "document_id": "5f1c2a90-...", "version_id": "1c0f6a3e-...", "index": 0, "label": "v1" }],
  "author": "Dana Reyes",
  "description": "Shorter term",
  "created_at": "2026-10-26T15:03:40Z",
  "metadata": {}
}
```

A version whose redline is a new comparison uses one comparison of an
[API trial's](/developers/reference/formats-and-limits#api-trials) allowance.

### A file you already hold

Both `POST /v1/documents` and `POST .../versions` take, in place of an uploaded `file`, the
`file_id` of a file you uploaded with [`POST /v1/files`](/developers/rest/files#post-v1files) or of an
edit's `files.clean`. The version takes that file as it is, not a copy: its listing, paragraph ids and
renderings are the version's, so the ids you read from the file carry over. The file then belongs to
the version, and deleting the version deletes it.

When the file is an edit's clean file and the version follows the edit's source, the version's
redline is the edit's own redline; nothing is compared again. So `POST /v1/files`, then
`POST /v1/edit`, then `POST /v1/documents` with the upload's `file_id`, then `POST .../versions` with
`files.clean.file_id` records the upload and the edit as v1 and v2.

| The file | Answer |
| --- | --- |
| Not yours, or deleted | `404 FILE_NOT_FOUND` |
| Already a version of a Document | `409 CONFLICT` |
| An edit's clean file whose edit is still being applied | `409 CONFLICT`, with `Retry-After` |
| An edit's clean file whose edit failed | `400 INVALID_REQUEST` |
| A redline, a merged draft, or a document uploaded to `POST /v1/compare` | `400 INVALID_REQUEST` |
| A zero-day retention file | `400 INVALID_REQUEST` |
| A workbook or presentation | `400 UNSUPPORTED_FILE_TYPE` |
| A file that could not be read when it was uploaded | `400 DOCUMENT_UNREADABLE` |
| Content deleted by retention | `410 CONTENT_DELETED` |

## GET /v1/documents

Lists the Documents your API keys created, newest first, without their versions.

| Parameter | In | Required | Default | Meaning |
| --- | --- | --- | --- | --- |
| `limit` | query | No | `50` | How many Documents to return, at most 200 |
| `cursor` | query | No | — | The previous page's `next_cursor` |

```json
{
  "documents": [
    { "document_id": "5f1c2a90-...", "name": "Master Services Agreement", "version_count": 2,
      "latest_version": { "document_id": "5f1c2a90-...", "version_id": "7b2d9e41-...", "index": 1, "label": "v2" },
      "created_at": "2026-10-26T15:02:11Z", "updated_at": "2026-10-26T15:03:40Z", "metadata": {} }
  ],
  "next_cursor": "eyJ..."
}
```

## GET /v1/documents/&#123;document_id&#125;

A Document and every version, in `index` order, each as a [Version object](#the-version-object).

| Parameter | In | Required | Default | Meaning |
| --- | --- | --- | --- | --- |
| `document_id` | path | Yes | — | The Document's id |
| `format` | query | No | `docx` | The formats of every version's file and redline: `pdf`, `html`, `docx`, `md`, `json`, as [`GET /v1/files/{file_id}`](/developers/rest/files#get-v1filesfileid) serves them. Repeatable or comma-separated |

| Field | Meaning |
| --- | --- |
| `document_id` | The Document's id |
| `name` | Its name |
| `version_count` | How many versions it has |
| `latest_version` | A reference to the version made last, the one `latest` names |
| `created_at`, `updated_at` | When it was made, and when it last changed |
| `metadata` | A free-form map of strings, stored and returned as you set it |
| `versions` | Every version, in `index` order |

## GET /v1/documents/&#123;document_id&#125;/versions/&#123;version_id&#125;

One version, with its file and its redline. `version_id` is the version's id, or `latest`.

| Parameter | In | Required | Default | Meaning |
| --- | --- | --- | --- | --- |
| `document_id` | path | Yes | — | The Document's id |
| `version_id` | path | Yes | — | The version's id, or `latest` |
| `format` | query | No | `docx` | The formats of the version's file and its redline, as for the Document |
| `inline` | query | No | `false` | `true` adds each ready download's bytes as `content_base64` |

```bash
curl -sS "https://api.versionstory.com/v1/documents/5f1c2a90-.../versions/latest?format=pdf,docx" \
  -H "Authorization: Bearer vs_live_..."
```

While a requested format is still being made, the response carries `Retry-After: 5`. Poll again
for it.

### The Version object

Every documents route that answers with a version answers with this object. A response that only
points at a version, such as a parent or a Document's `latest_version`, carries the reference alone:
`document_id`, `version_id`, `index` and `label`.

| Field | Meaning |
| --- | --- |
| `document_id`, `version_id` | The Document and the version |
| `index` | The version's position among the Document's versions, in the order they were made, counted from 0. It moves when an earlier version is deleted |
| `label` | The label the app shows: `v2`, or `v2(a)` for one of parallel drafts. A version added later can change it |
| `name` | The version's own name once renamed, else its file's |
| `files.clean` | The document as of this version, a [File object](/developers/rest/files) |
| `files.comparison` | Its redline against the version before it, a File object. The first version has none |
| `parents` | References to the versions it was made from |
| `author`, `description` | Who its changes are credited to, and what changed |
| `created_at` | When it was made |
| `metadata` | A free-form map of strings |

Each file is exactly what `GET /v1/files/{file_id}` answers for it in the requested formats, so it is
downloaded as every other file is. A redline whose comparison failed is its file with
`"status": "failed"` and an `error`.

## POST /v1/documents/&#123;document_id&#125;/elements/lookup

Follows elements of one version (paragraphs, tables, rows and cells) through the Document's other
versions. Each version's redline keeps a map between the version it compares against and its own
file, and the lookup follows those maps forward and back from the version you name.

| Field | Required | Meaning |
| --- | --- | --- |
| `version` | Yes | The version the ids are from, as a locator: `{"version_id": "..."}` |
| `ids` | Yes | 1 to 1,000 ids of that version's body paragraphs, tables, rows and cells, as its listing prints them. Each is answered once, in the order given |
| `versions` | No | The versions to answer for, as locators. Default: every version |
| `formats` | No | `md`, `json` or both: each element's content in each version where it has ids. Without it, only ids come back |
| `show_changes` | No | `true` reads the content from each version's redline, with its tracked changes; `false` from the version's file. A first version has no redline, so it reads clean. Default `true` |

Ids are each version's own, as its listing prints them
(`GET /v1/files/{file_id}?format=md` or `json` for the version's `files.clean.file_id`). Only the
body's elements are followed, not those of headers, footers or notes.

The answer has one entry per requested id, each with its `type` and, for every version asked for in
`index` order, the element's `ids` there:

| `ids` | Means |
| --- | --- |
| One id | The element is in that version under this id |
| Several ids | The element was split, such as a table split in two |
| `[]` | The element was deleted by that version, or that version was made before the element was added |

With `formats`, `content` holds the element as that version's listing renders it, where it has ids:
`md`, its lines of the Markdown listing, and `json`, its [listing blocks](/developers/reference/json-format#blocks)
(a paragraph's block, a table with its rows and cells, a row with its cells, or a cell with its
`index`, `id`, `column`, `span` when merged, and `blocks`).

| Status | Code | Cause |
| --- | --- | --- |
| 400 | `ELEMENT_NOT_FOUND` | An id is no element of the version's body, or more than one element carries it. Each is named |
| 409 | `ELEMENT_MAP_PENDING` | A version's redline, or a listing the content is read from, is still being made. Retry after `Retry-After` |
| 409 | `ELEMENT_MAP_UNAVAILABLE` | A map the lookup needs does not exist and will not, such as one whose redline failed or was made before maps were kept. The version is named |

The lookup only reads: it never makes a missing map, so `ELEMENT_MAP_UNAVAILABLE` is final for that
version.

## Worked example: following a clause

A Master Services Agreement has two versions. In v1, clause 9 sets the term and the clause after it
covers renewal. You want to know what became of both in v2.

**1. Create the Document and add v2**, as in the requests above. v1's version is `1c0f6a3e-...`
and its file `3f2a9c1e-...`.

**2. Read v1's listing** and copy the ids of the two paragraphs:

```bash
curl -sS "https://api.versionstory.com/v1/files/3f2a9c1e-...?format=md" \
  -H "Authorization: Bearer vs_live_..."
```

Download the `md` from `downloads[0].url`. Its lines for the two paragraphs are:

```text
<p index="15" id="088af089d5b64bd1a3f6e2c0b9d14e7a" label="9." list_level="1" style="ListNumber"/>This Agreement will terminate five (5) years after the Effective Date.
<p index="16" id="fe77f633a41c4d0e8b2a9c7d6e5f4a3b" label="10." list_level="1"/>This Agreement renews for successive one (1) year terms unless either party gives notice.
```

**3. Look them up**, with their content as Markdown:

```bash
curl -sS https://api.versionstory.com/v1/documents/5f1c2a90-.../elements/lookup \
  -H "Authorization: Bearer vs_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "version": {"version_id": "1c0f6a3e-..."},
    "ids": ["088af089d5b64bd1a3f6e2c0b9d14e7a", "fe77f633a41c4d0e8b2a9c7d6e5f4a3b"],
    "formats": ["md"]
  }'
```

A `409 ELEMENT_MAP_PENDING` means v2's redline is still being made; send the request again after
`Retry-After`.

**4. Read the answer:**

```json
{
  "elements": [
    {
      "id": "088af089d5b64bd1a3f6e2c0b9d14e7a",
      "type": "paragraph",
      "versions": [
        {
          "version": { "document_id": "5f1c2a90-...", "version_id": "1c0f6a3e-...", "index": 0, "label": "v1" },
          "ids": ["088af089d5b64bd1a3f6e2c0b9d14e7a"],
          "content": { "md": "<p index=\"15\" id=\"088af089d5b64bd1a3f6e2c0b9d14e7a\" label=\"9.\" list_level=\"1\" style=\"ListNumber\"/>This Agreement will terminate five (5) years after the Effective Date." }
        },
        {
          "version": { "document_id": "5f1c2a90-...", "version_id": "7b2d9e41-...", "index": 1, "label": "v2" },
          "ids": ["2e37d0a9c81f4b5e9d6a7f3b2c1e0d4f"],
          "content": { "md": "<p index=\"15\" id=\"2e37d0a9c81f4b5e9d6a7f3b2c1e0d4f\" label=\"9.\" list_level=\"1\" style=\"ListNumber\"/>This Agreement will terminate <del revision=\"0\">five (5)</del><ins revision=\"1\">three (3)</ins> years after the Effective Date." }
        }
      ]
    },
    {
      "id": "fe77f633a41c4d0e8b2a9c7d6e5f4a3b",
      "type": "paragraph",
      "versions": [
        {
          "version": { "document_id": "5f1c2a90-...", "version_id": "1c0f6a3e-...", "index": 0, "label": "v1" },
          "ids": ["fe77f633a41c4d0e8b2a9c7d6e5f4a3b"],
          "content": { "md": "<p index=\"16\" id=\"fe77f633a41c4d0e8b2a9c7d6e5f4a3b\" label=\"10.\" list_level=\"1\" style=\"ListNumber\"/>This Agreement renews for successive one (1) year terms unless either party gives notice." }
        },
        {
          "version": { "document_id": "5f1c2a90-...", "version_id": "7b2d9e41-...", "index": 1, "label": "v2" },
          "ids": []
        }
      ]
    }
  ]
}
```

- **Clause 9** is `2e37d0a9...` in v2. Its content comes from v2's redline, so it shows the change:
  the term went from five years to three. Store that id to name the clause in v2, for instance in a
  [`vs:scrollTo`](/developers/rest/embed#linking-to-a-place-in-the-document) or an
  [edit](/developers/rest/edit) of v2's file.
- **The renewal clause** has `ids: []` in v2: v2 deleted it. It has no `content` there.

## Errors

| Status | Code | Cause |
| --- | --- | --- |
| 400 | `INVALID_REQUEST` | Both `file` and `file_id`, or neither; a `parent` that is not a version locator; a version reference that names another Document; a blank `author_name`, or one over 100 characters |
| 400 | `INVALID_DOCUMENT_ID`, `INVALID_VERSION_ID`, `INVALID_FILE_ID` | An id is malformed. A `version_id` is a UUID or `latest` |
| 400 | `INVALID_PARENT_VERSION` | `parent` names no version of this Document |
| 400 | `UNSUPPORTED_FILE_TYPE` | A file is not a `.docx`, `.doc`, `.pdf`, `.eml` or `.msg` |
| 400 | `UNSUPPORTED_FORMAT` | A requested `format` is unknown |
| 400 | `INVALID_CURSOR` | The `cursor` is malformed |
| 400 | `ELEMENT_NOT_FOUND` | A lookup id is no element of the version's body. See [the lookup](#post-v1documentsdocumentidelementslookup) |
| 401 | `INVALID_API_KEY` | Missing, malformed, unknown, revoked, or expired key |
| 402 | `USAGE_LIMIT_REACHED` | The organization's monthly upload allowance is exhausted |
| 403 | `ZERO_DAY_RETENTION_UNSUPPORTED` | The organization has zero-day retention, which keeps no versions |
| 404 | `DOCUMENT_NOT_FOUND` | No such Document, or it belongs to another organization |
| 404 | `VERSION_NOT_FOUND` | No such version in this Document |
| 404 | `FILE_NOT_FOUND` | A `file_id` names no file of yours |
| 409 | `CONFLICT` | A held file is already a version, or is still being made |
| 409 | `ELEMENT_MAP_PENDING`, `ELEMENT_MAP_UNAVAILABLE` | A lookup cannot follow its elements yet, or at all |
| 410 | `CONTENT_DELETED` | A held file's content was deleted under zero-day retention |
| 413 | `FILE_TOO_LARGE` | A file exceeds the 100 MB limit |

The machine-readable definition of each route is in the
[OpenAPI specification](/developers/rest/reference#the-openapi-specification).

## Related

- [Edit](/developers/rest/edit#versions): an edit of a version's file is kept as the next version
- [Merge](/developers/rest/merge): merge versions by `document_id` and `version_id`
- [Document JSON](/developers/reference/json-format): the listing blocks a lookup returns
- [Files](/developers/rest/files): the File object each version's `files` holds

---

<!-- https://www.versionstory.com/developers/rest/files -->

# Files

> Upload a document once, then download it as a PDF, as Word, as embeddable HTML or as its listing, with or without its metadata.

> Integrating Version Story into a codebase as a coding agent? Follow https://www.versionstory.com/developers/quickstart.md: it signs the engineer in, gets an API key, and puts the PDF redline in their app first.

A file is one immutable document held by Version Story, named by a `file_id`. Uploads, a
comparison's redline, an edit's results and created documents are all files, and every endpoint
that reads a document takes its `file_id`. Every response that carries a document carries it as the
same File object, the one `GET /v1/files/{file_id}` answers with.

| | |
| --- | --- |
| Base URL | `https://api.versionstory.com` |
| Auth | `Authorization: Bearer vs_...`. See [Authentication](/developers/authentication) |

## POST /v1/files

`multipart/form-data` with one file field, `document`: a `.docx`, `.doc` or `.pdf` to read, edit or
show, or a `.xlsx` or `.pptx` to compare with another of its kind.

```bash
curl -sS https://api.versionstory.com/v1/files \
  -H "Authorization: Bearer vs_live_..." \
  -F "document=@nda.docx"
```

```json
{ "file_id": "3f2a9c1e-...", "status": "processing" }
```

- **A `.docx`** answers at once while it is prepared in the background. `processing` needs no polling
  of its own: every request that reads the file waits for it.
- **A `.doc` or `.pdf`** is converted to Word first. The request waits up to 20 seconds and answers
  `ready`, `failed` with an `error`, or `processing` if conversion is still running. A redline PDF's
  insertions and deletions become tracked changes; send the optional field
  `track_redline_changes=false` to keep them as formatting. See
  [Redline PDFs](/developers/reference/formats-and-limits#redline-pdfs).
- **A workbook or presentation** is stored as it is and answers `ready`. It can be compared, never
  read or edited.

## GET /v1/files/&#123;file_id&#125;

Signed downloads for one file. The `pdf` shows the file's own tracked changes and comments as a
redline: it is what to show a person.

| Parameter | In | Required | Default | Meaning |
| --- | --- | --- | --- | --- |
| `file_id` | path | Yes | — | The file's id |
| `format` | query | No | `docx` | One or more of `pdf`, `html`, `docx`, `md`, `json`. Repeatable or comma-separated. A workbook or presentation is served as it is (`xlsx`, `pptx`) |
| `clean_metadata` | query | No | — | `standard` or `strict`: serve every format with its metadata removed. See [Clean metadata](#clean-metadata) |
| `clean_metadata_revisions` | query | No | the preset's | With `clean_metadata`: `keep`, `accept` or `reject` tracked changes |
| `clean_metadata_comments` | query | No | the preset's | With `clean_metadata`: `keep` or `remove` comments |
| `clean_metadata_authors` | query | No | the preset's | With `clean_metadata`: `keep` or `anonymize` the names on changes and comments |
| `search` | query | No | — | `md` only: return only the paragraphs holding a phrase. See [Reading the listing](#reading-the-listing) |
| `offset`, `max_chars` | query | No | — | `md` only: read the listing in parts. See [Reading the listing](#reading-the-listing) |

| `format` | Is |
| --- | --- |
| `pdf` | The document as a PDF, with its tracked changes and comments shown. The view to show a person |
| `html` | The document as one self-contained HTML file, to frame where the view must be interactive. See [Embed redlines](/developers/rest/embed) |
| `docx` | The Word file |
| `md` | The document's listing as Markdown: its text, each paragraph's locator, and its tracked changes. What edits are written from, and what a model reads. See [Edit](/developers/rest/edit#the-listing) |
| `json` | The same listing as structured data, for code. See [Document JSON](/developers/reference/json-format) |

```bash
curl -sS "https://api.versionstory.com/v1/files/3f2a9c1e-...?format=pdf,docx" \
  -H "Authorization: Bearer vs_live_..."
```

```json
{
  "file_id": "3f2a9c1e-...",
  "file_name": "nda.docx",
  "status": "processing",
  "downloads": [
    {
      "format": "docx",
      "url": "https://documents.versionstory.com/...",
      "file_name": "nda.docx",
      "expires_at": "2026-10-22T18:00:00Z"
    }
  ],
  "pending_formats": ["pdf"]
}
```

`status` is the state of the formats this request asked for: `processing` while one is still being
made (the response carries `Retry-After`; poll again), `ready` when every one is in `downloads`, and
`failed` when the document cannot be used or a requested format will never be made. The first request
for a PDF or HTML starts making it. A format that failed is named in `failed_formats`, and a file whose
source could not be read at all answers `"status": "failed"` with an `error`.

Signed links are valid for one hour, and polling again mints fresh ones. A workbook or presentation
has no `pdf`, `html`, `md` or `json`, and asking for one is `400 UNSUPPORTED_FORMAT`.

### Reading the listing

The `md` listing of a long document can be large. Two ways to read only part of it, both for `md`
alone:

- **`search`**, repeated for several phrases (`?format=md&search=Closing&search=36%20months`). Once
  the listing is ready, the response adds `markdown`, holding only the paragraphs that contain a
  phrase, each after the lines that say where it is (its header, footer, note, table, row and cell).
  `matches` gives each phrase with the number of paragraphs holding it. A phrase matches as an edit's
  text selector does, ignoring case. The paragraphs stop at 48,000 characters, and
  `omitted_paragraphs` then counts those left out.
- **`offset` and `max_chars`** read the listing in parts. `markdown` holds the part starting at
  `offset` (0, or the last response's `next_offset`) and ending at the last whole line that fits in
  `max_chars`. The response adds `total_chars` and, until the part reaches the end, `next_offset`.

`search` takes neither `offset` nor `max_chars`; sending them together, or with any format but `md`,
is `400 INVALID_REQUEST`.

### Clean metadata

A document carries more than its text: who wrote and edited it, when, on which template and from
which network folder, its comments and tracked changes, hidden text, and the earlier revisions a PDF
keeps. Add `clean_metadata` to download a copy without it, to send outside the firm.

```bash
curl -sS "https://api.versionstory.com/v1/files/3f2a9c1e-...?format=pdf,docx&clean_metadata=standard" \
  -H "Authorization: Bearer vs_live_..."
```

| Preset | What it removes |
| --- | --- |
| `standard` | What nobody sees on the page: document properties, people and account ids, revision ids, hidden text, document variables, mail merge sources, custom XML, printer settings, image metadata, and a PDF's XMP, earlier revisions and JavaScript. Tracked changes, comments and authors' names stay |
| `strict` | Everything `standard` removes, and the review: tracked changes accepted, comments, PDF notes and speaker notes removed, authors anonymized, white or tiny text, hidden sheets and slides, and macros removed, and PDF form fields and stamps flattened into the page |

The three choices override the preset: `clean_metadata=standard&clean_metadata_comments=remove` keeps
tracked changes but removes comments. Each choice needs `clean_metadata`; one sent without it is
`400 INVALID_REQUEST`, so a request never gets the uncleaned document by mistake. `clean_metadata`
cleans documents, not listings, so it does not apply to `md`, `json` or `html`.

Each download in the response is the cleaned copy, with a `metadata_report`:

```json
{
  "format": "docx",
  "url": "https://documents.versionstory.com/...",
  "file_name": "nda.docx",
  "expires_at": "2026-10-22T18:00:00Z",
  "metadata_report": {
    "format": "docx",
    "preset": "standard",
    "items": [
      { "type": "author", "category": "properties", "count": 1, "action": "remove", "examples": [] },
      { "type": "comments", "category": "review", "count": 4, "action": "keep", "examples": [] }
    ],
    "warnings": [
      { "type": "document_numbers", "message": "A header or footer shows a document number or file path. It is visible text, so it was left in place." }
    ]
  }
}
```

`items` lists every kind of hidden data found and the `action` taken: what was removed, reset or
converted, and with `keep` or `report`, what was left in place. Show the `warnings` to whoever sends
the document; they name what the cleaner cannot decide for you, such as visible document numbers or
text under a dark box that only looks redacted. Every type and what each preset does to it is in
[Metadata types](/developers/reference/metadata).

A PDF that Version Story made from a Word file (a redline's, or an uploaded document's) draws its
comments, their authors and its tracked changes onto the page, where no clean of the PDF can reach
them. So its clean cleans the Word file with the same options and renders the PDF again: what the
options remove from the Word file is gone from the PDF too. A comparison's redline is cleaned through
this endpoint by its `file.file_id`.

A format whose clean is still running is in `pending_formats`, as a PDF still converting is; poll
until it is `ready`. One that cannot be cleaned is in `failed_formats`, and its uncleaned original is
never served in its place. Word, Excel, PowerPoint and PDF files are cleaned, and each copy is kept for
the options that made it, so asking again is immediate.

## POST /v1/files/&#123;file_id&#125;/acknowledge

For an organization with [zero-day retention](/developers/reference/data-handling#retention), deletes
an uploaded file or a created document as soon as you have saved what you need, instead of an hour
after it was created. No body.

```bash
curl -sS -X POST https://api.versionstory.com/v1/files/3f2a9c1e-.../acknowledge \
  -H "Authorization: Bearer vs_live_..."
```

```json
{ "file_id": "3f2a9c1e-...", "status": "deletion_queued" }
```

An edit's files are deleted with the edit: acknowledge those with
[`POST /v1/edit/{edit_id}/acknowledge`](/developers/rest/edit#post-v1editeditidacknowledge).

## Errors

| Status | Code | Cause |
| --- | --- | --- |
| 400 | `INVALID_REQUEST` | A missing or empty `document`; an unknown `clean_metadata` value, a choice sent without it, or `clean_metadata` with `md`, `json` or `html`; or `search`, `offset` or `max_chars` sent together or with a format other than `md` |
| 400 | `UNSUPPORTED_FILE_TYPE` | The upload is not a `.docx`, `.doc`, `.pdf`, `.xlsx` or `.pptx` |
| 400 | `DOCUMENT_UNREADABLE` | The upload is not a readable Word file: corrupt, or encrypted |
| 400 | `UNSUPPORTED_FORMAT` | A `format` is unknown, or not offered for this file |
| 401 | `INVALID_API_KEY` | Missing, malformed, unknown, revoked, or expired key |
| 402 | `USAGE_LIMIT_REACHED` | The organization's monthly upload allowance is exhausted |
| 404 | `FILE_NOT_FOUND` | No such file, or it belongs to another organization |
| 409 | `CONFLICT` | An acknowledgment of a file without zero-day retention, or of an edit's own file |
| 410 | `CONTENT_DELETED` | The file's content was deleted under zero-day retention |
| 413 | `FILE_TOO_LARGE` | The upload exceeds the 100 MB limit |

### Kept for existing integrations

`GET /v1/files/{file_id}/content` answers the listing in the response body rather than as a download:
`markdown` with `format=md`, `document` with `format=json`. It is deprecated, and marked so in the
OpenAPI specification; read the `md` and `json` downloads above instead. Its `document` gives `level`
the meaning the download calls `list_level`.

## Related

- [Edit](/developers/rest/edit): change a document with tracked changes
- [Embed redlines](/developers/rest/embed): frame a document's HTML
- [Metadata types](/developers/reference/metadata): every kind of hidden data a clean reports
- [Data handling](/developers/reference/data-handling): what is kept, and for how long

---

<!-- https://www.versionstory.com/developers/rest/edit-operations -->

# Edit operations

> Every edit POST /v1/edit and POST /v1/create take: locators, text selectors, content runs, property values, how a request is applied, and each operation with its fields, examples and refusals.

> Integrating Version Story into a codebase as a coding agent? Follow https://www.versionstory.com/developers/quickstart.md: it signs the engineer in, gets an API key, and puts the PDF redline in their app first.

`edits` is a JSON array of edit objects, applied in request order. Each has a `type` and the fields that
type defines; unknown fields are refused. Their machine-readable definitions are in the OpenAPI specification
under `components.schemas.EditRequest`. [How a request is applied](#how-a-request-is-applied) says how edits
in one request combine.

| Operation | Changes | Names | Tracked |
|---|---|---|---|
| [`insert`](#insert) | new text, breaks, fields, cross-references, notes and links at a point | paragraphs and a point | yes; a cross-reference's bookmark, a new notes part and note styles are not |
| [`replace`, `delete`](#replace-and-delete) | text, or a page break | paragraphs and a selector | yes |
| [`format`](#format) | character formatting | paragraphs, and optionally a selector | yes |
| [`insert_paragraphs`](#insertparagraphs) | new paragraphs | a destination | yes; a new list's definitions are not |
| [`delete_paragraphs`](#deleteparagraphs) | whole paragraphs, and tables a range covers | paragraphs | yes |
| [`move_paragraphs`](#moveparagraphs) | where paragraphs and tables are | paragraphs and a destination | yes |
| [`split_paragraph`](#splitparagraph) | one paragraph into two | a paragraph and a point | yes |
| [`join_paragraphs`](#joinparagraphs) | a range of paragraphs into one | a range | yes |
| [`set_paragraph_style`](#setparagraphstyle) | paragraph style | paragraphs | yes |
| [`set_paragraph_format`](#setparagraphformat) | alignment, indents, spacing, pagination | paragraphs | yes |
| [`set_style_format`](#setstyleformat) | a paragraph style's alignment, indents, spacing, pagination | a style | no: a style definition |
| [`rename_style`](#renamestyle) | a style's name and id | a style | no: a style definition |
| [`set_numbering`](#setnumbering) | list numbering | paragraphs | yes; new list instances and definitions are not |
| [`insert_section_break`](#insertsectionbreak) | a new section break | the paragraph that ends the section | yes |
| [`remove_section_break`](#removesectionbreak) | a section break | a section | yes; with `keep: "earlier"`, the next section's header and footer references are not |
| [`set_page_setup`](#setpagesetup) | orientation, paper, margins, first page, page numbering | sections | yes |
| [`add_header_footer`](#addheaderfooter) | a new header or footer | a section | its content; the new part and its reference are not |
| [`insert_table`](#inserttable) | a new table | a destination | yes |
| [`delete_table`](#deletetable) | a table | a table | yes |
| [`insert_table_row`, `delete_table_row`](#inserttablerow-and-deletetablerow) | a row | a row | yes |
| [`insert_table_column`, `delete_table_column`](#inserttablecolumn-and-deletetablecolumn) | a column | a column | yes |
| [`set_table_format`, `set_cell_format`](#settableformat-and-setcellformat) | a table's or cells' borders | a table, or cells | yes |
| [`add_hyperlink`](#addhyperlink) | a link on existing text | a paragraph, and optionally a selector | yes |
| [`set_hyperlink`, `remove_hyperlink`](#sethyperlink-and-removehyperlink) | a link's address, or the link | a paragraph, and optionally a selector | yes |
| [`add_bookmark`](#addbookmark) | a named span fields and cross-references point to | paragraphs, or a paragraph and a selector | no: a bookmark is not a revision |
| [`refresh_fields`](#refreshfields) | when Word updates fields | nothing | no |
| [`insert_table_of_contents`](#inserttableofcontents) | a new table of contents | a destination | yes; its update-on-open flags are not |
| [`delete_note`](#deletenote) | a footnote or endnote | a note | yes |
| [`add_comment`](#addcomment) | a new comment | a paragraph or range, and optionally a selector; or a revision | no: a comment is an annotation |
| [`reply_comment`, `resolve_comment`](#replycomment-and-resolvecomment) | a comment thread | a comment | no |
| [`accept_revision`, `reject_revision`](#acceptrevision-and-rejectrevision) | existing tracked changes | revisions | settles them |
| [`set_document_settings`](#setdocumentsettings) | Track Changes, editing restriction, personal information | nothing | no |

## Tracked and untracked

Every change to document content is a real Word tracked change carrying the request's `author` and
`date`: the redline shows it for review, the clean copy accepts it, and `accept_revision` or
`reject_revision` decides it like any other revision. What Word cannot track is applied directly, to both
outputs, and stays when revisions are accepted or rejected:

- `set_document_settings`: the settings it names (`w:trackRevisions`, `w:documentProtection`,
  `w:removePersonalInformation`) and the file properties' author names.
- `refresh_fields` and `insert_table_of_contents`: fields' `w:dirty` flags and `w:updateFields` in the
  settings. The table of contents' paragraph is tracked.
- Comments from `add_comment`, `reply_comment` and `resolve_comment`: annotations, not revisions.
- New list instances (`w:num`) and definitions (`w:abstractNum`), and `numbering.xml` when the document
  has none, that `set_numbering` and `insert_paragraphs` lists need. The paragraphs that use them change
  as tracked changes.
- A `cross_reference` run: the hidden `_Ref` bookmark on the paragraph pointed to, when it has none.
- A `footnote` or `endnote` run: the notes part, with Word's separator notes, and the note styles, when
  the document lacks them.
- `add_hyperlink` and `hyperlink` runs: the Hyperlink character style, when the document lacks one.
- `add_header_footer`: the new header or footer part and the section's reference to it; its content is
  tracked.
- `remove_section_break` with `keep: "earlier"`: the header and footer references the next section
  takes from the removed one. The break and the next section's page setup are tracked.
- `set_style_format`: the paragraph style's definition (`w:style/w:pPr`), as Word applies a modified style.
- `rename_style`: the style's name and id and every reference to its id, as Word renames a style.

## Locators

Everything an edit names is a locator object, never a bare number. The listing prints every locator
([The content listing](/developers/rest/edit#the-listing)), so agents copy locators instead of computing them. A locator names
its target by `index`, where it is now, counted from 0 within its container, or by `id`, what it is, which it keeps
when content moves; paragraphs, tables, rows and cells have both, and a locator may give both when they name the same
thing (otherwise it is refused). Beside an id, an index needs no container: it counts within the container of the
thing the id names, so a header's paragraph, a row or a cell printed as `{"index", "id"}` is sent as printed. Every
reference the listing prints is a locator a request can send as it is.

### Paragraph locators

```jsonc
{"index": 12}                                              // body paragraph 12
{"id": "8532bf246ab64e8788c26d27cfd105b4"}                 // the paragraph with this id, in any part
{"index": 12, "id": "8532bf246ab64e8788c26d27cfd105b4"}    // both, naming the same paragraph
{"part": {"kind": "footnote", "index": 2}, "index": 0}     // first paragraph of footnote 2
{"part": {"kind": "header", "type": "first", "section": {"index": 1}}, "index": 0}
{"part": {"ref": "fn1"}, "index": 0}                       // a note or header this request creates
{"ref": "p1"}                                              // a paragraph this request creates
```

- **`index`.** It counts the paragraphs of a part from 0, in listing order, including paragraphs in table
  cells. `part` goes with `index`; an `id` names its paragraph in whatever part it is.
- **Tracked text.** The document is seen as the listing shows it: tracked insertions are included and
  tracked deletions left out. A paragraph whose mark is a tracked deletion is still listed and counted.
- **Parts.**
  - `kind` is `body` (the default when `part` is omitted), `footnote`, `endnote`, `header` or `footer`.
  - A note's `index` is its position among notes of its kind, in document order. That isn't the mark the
    reader sees, which can be custom or restart per section; the listing prints that as `label`.
  - A header or footer has a `type` (`default`, `first` or `even`) and a `section` locator. Together
    they name the part that section's pages show.
  - A part that several sections show through Word's linking is one part. The listing prints it once,
    with its canonical locator (the first section that shows it) and every section that shows it. A
    locator naming any of those sections reaches the same part.
  - Editing a shared part changes every section that shows it; the listing's `sections` says which.
  - A section that `insert_section_break` creates shows the same parts as the section it was split from,
    as in Word, so its header locator reaches those parts.
  - Even-page headers and footers that already exist can be edited but not created. Creating one needs the
    document-wide even-and-odd setting, which would leave every other section's even pages blank.
- **An id that names more than one element.** An id names one paragraph, table, row or cell. When a document
  gives one id to more than one of them (a comparison's redline can repeat a header's or footer's paragraph ids, in
  the parts it shows and in a part no section shows), an edit that names one of them is refused with 400
  `INVALID_EDIT`, which lists every place the id is. This holds however the edit names it: by `id`, by `index`, or
  through `"all"`, a range or a selector's match. Content whose ids nothing else carries is edited as usual.
- **Out of reach.** Text boxes and comment bodies have no locator, and `"all"` doesn't reach them. When an
  edit over `"all"` leaves out a text box holding text it would otherwise have acted on, nothing reports
  it. Content controls sit inside paragraphs, so their text is ordinary paragraph text.

### Other locators

| Names | Locator |
|---|---|
| a section | `{"index": 1}`; `set_page_setup` also takes an array of section locators, or `"all"` |
| a table | `{"index": 1}`: the tables of a part in document order, an outer table before the tables nested in it. Body is the default; a table elsewhere adds `part`. Or `{"id": "…"}`, in any part. |
| a row | `{"table": {...}, "index": 3}` among its table's rows, `{"id": "…"}`, or `{"paragraph": locator}`, the row holding that paragraph in the innermost table holding it |
| a column | `{"table": {...}, "index": 2}`, counting the table's grid columns from 0 (a cell's `column` in the listing, never its index), or `{"paragraph": locator}`, the column of the cell holding that paragraph; a paragraph in a cell that spans several grid columns can't name a column |
| a cell | `{"row": {...}, "index": 1}` among its row's cells, `{"id": "…"}`, or `{"paragraph": locator}` |
| a comment thread | `{"index": 2}`: top-level threads in document order. Replies aren't counted, since replying and resolving act on a thread. |
| a note | `{"kind": "footnote", "index": 2}` |
| revisions | `{"index": 4}`, one entry of the listing's revisions; `"all"`; or a filter `{"author"?: "…", "type"?: "insertion" \| "deletion" \| "move" \| "property"}`, where `property` covers every formatting, paragraph, numbering, table and section property change |

Any locator but a cell's may instead be `{"ref": "…"}`, which names something this request creates
([Refs](#refs)).

### Counting

Every `index` counts from 0, because it is a position in the listing's arrays: paragraphs, tables, rows, cells,
grid columns, sections, notes, comment threads, pictures and revisions. What counts from 1 counts as a reader does:
`occurrence`; `list_level` (1 to 9, as Word's interface shows it), so `pattern` writes level 3 as `%3`; and
table-of-contents heading levels.

### `paragraphs`

Every operation that acts on paragraphs names them in one required field, `paragraphs`:

| Form | Names |
|---|---|
| a locator | that paragraph |
| `{"from": locator, "through": locator}` | the uploaded paragraphs between the two, inclusive, in uploaded order, within one part. A range of refs from one `insert_paragraphs` names those new paragraphs. |
| an array of locators and ranges | each of them; a paragraph named twice counts once |
| `"all"` | every paragraph of every part (body, headers, footers, footnotes, endnotes) |
| `{"all": true, "part": {...}}` | every paragraph of the matching parts; a `part` with only `kind` matches every part of that kind. `part` is required in this form. |

- **No implicit scope.** A whole-document `replace` says `"paragraphs": "all"`.
- **Only uploaded paragraphs.** The `"all"` forms, and ranges between uploaded locators, name uploaded
  paragraphs only ([How a request is applied](#how-a-request-is-applied)).
- **Ranges and tables.**
  - A range that covers a whole table includes the table, so moving or deleting the range moves or deletes
    the table.
  - A range for `delete_paragraphs`, `move_paragraphs` or `join_paragraphs` that covers only part of a
    table is refused, whichever end is inside it. So is one that spans more than one cell. A range wholly
    inside one cell is fine.
  - Deleting every paragraph of a cell is refused.
- **How many.** `split_paragraph`, `add_hyperlink`, `set_hyperlink` and `remove_hyperlink` take exactly one
  locator, since a Word hyperlink can't cross paragraphs. `add_comment` takes one locator or one range; a
  range makes one comment spanning it. `join_paragraphs` takes one range.

### Destinations

`insert_paragraphs`, `move_paragraphs`, `insert_table` and `insert_table_of_contents` place content with
`"destination": {"paragraph": locator, "position": "before" | "after"}`.

- A destination at a paragraph in a table cell places the content in that cell. An `insert_table` there
  makes a nested table.
- `{"table": locator, "position": "before" | "after"}` places content directly before or after a table.
- `{"position": "start" | "end"}`, naming no paragraph or table, places content at the start of the body,
  before its first block, or at its end, after its last block. `start` and `end` take no paragraph or table, and
  `before` and `after` need one. Content placed at the start comes in request order;
  content placed at the end goes after what earlier edits placed there. A table placed at the end gets the
  empty paragraph Word needs after it, and content placed at the end later goes before that paragraph and,
  when it ends in a paragraph, takes its place. A document with no paragraph to name, such as the blank
  document `POST /v1/create` starts from, takes its first content here.
- Content placed after one paragraph or table comes before content placed before the next one.
- `insert_table_row` uses `{"row": locator, "position": …}`, and `insert_table_column` uses
  `{"column": locator, "position": …}`.

### Refs

An edit that creates something can carry `"ref": "<name>"`, and later edits use `{"ref": "<name>"}` wherever
that kind of locator is expected. Ref names form one namespace per request, and each is defined once,
before it is used.

| Created by | Names |
|---|---|
| `new_paragraphs` items | the new paragraph |
| `split_paragraph` | the second half |
| `insert_section_break` | the new section after the break; the original `{"index": n}` still names the section before it |
| `insert_table`, `insert_table_row`, `insert_table_column` | the new table, row or column |
| `add_header_footer` | the new part: `{"part": {"ref": "hdr1"}, "index": 0}` |
| note runs | the new note: `{"part": {"ref": "fn1"}, "index": 0}` |
| `add_comment` | the new thread: `reply_comment` with `"comment": {"ref": "c1"}` |

## Selecting text

`text` selects text in the named paragraphs, and `at` names an insertion point:

```text
"text": {"match": "Series A Preferred Stock", "prefix": "the ", "suffix": " issued", "occurrence": 2}
"at": "start" | "end"
"at": {"after": {"match": "Name:\t"}}       // a selector: prefix, suffix and occurrence apply
"at": {"before": {"match": "Exhibit"}}
```

`insert` and `split_paragraph` take `at`. An `insert` applies once at each point it names: with `"start"` or
`"end"`, once per named paragraph; with a selector, once per match.

- **What it matches.** A selector matches only the uploaded text of the named paragraphs, and a match never
  crosses a paragraph boundary.
- **Matching rules.**
  - Matching is case-sensitive.
  - In a `match` string, `\t` matches a tab run and `\n` a line break.
  - These characters are read alike: a non-breaking, thin or hair space and a space; curly and straight
    quotes; en and em dashes and a hyphen; a character with a one-character Unicode compatibility form and
    that form. Nothing else is folded (`…` is not `...`).
- **Run form.** A `match` is a string or an array of runs. The run form is for content with no characters:
  `[{"type": "break", "kind": "page"}]` matches a page break and `[{"type": "picture", "index": n}]` the
  listing's picture `n`, for `delete` only, without `prefix` or `suffix`. The picture must be in the named
  paragraphs, and not in a tracked deletion or a move's old place.
- **`prefix` and `suffix`.** Optional text that must sit immediately before and after the match. They
  filter the candidates before counting, and the edit never acts on them.
- **`occurrence`.** It counts across every paragraph the edit names, in listing order: the body; for each
  section in turn, its header then its footer, each in the order `default`, `first`, `even`; footnotes;
  endnotes. A shared part counts once, at its canonical section.
  - Omitted: the match must be unique across them.
  - `N`: the Nth match, e.g. the second "Acme" in the document with `"paragraphs": "all"`.
  - `"all"`: every match. At least one must exist.
- **Character ranges are half-open.** Two that touch end to end don't overlap.
- **One match when an edit creates something.**
  - `add_comment`, `add_hyperlink`, `set_hyperlink`, `remove_hyperlink` and `split_paragraph` must select
    exactly one range or point.
  - So must an `insert` whose `content` holds a note run with a `ref`.
  - `split_paragraph` at `"start"` or `"end"` is refused, since it would make an empty paragraph. Use
    `insert_paragraphs` for that.
  - `add_hyperlink` over text that is already linked is refused. Use `set_hyperlink`.
- **Required or optional.**
  - `delete` and `replace` require `text`.
  - `format`, `add_comment` and `add_hyperlink` without `text` act on the whole paragraph.
  - `set_hyperlink` and `remove_hyperlink` without `text` act on the paragraph's only hyperlink.
- **New content.** Text in a paragraph this request creates can't be selected, since it isn't in the
  listing. The paragraph itself can be named by ref for paragraph-level edits. New sections, tables, rows,
  columns, notes and comments can be named by ref by any edit that takes that kind of locator.
- **Hidden content.** Note references, comment anchors, bookmarks, pictures and objects are skipped by matching, so
  `"409A."` matches across a footnote mark between "409A" and ".". A `replace` or `delete` whose range
  would contain a note reference or image is refused. Comment anchors and bookmarks stay in place.
- **Field results.** A field's computed result counts as text, as the listing shows it, and can anchor a
  selector, but a `replace`, `delete` or `format` range holding part of a result, and an insertion point
  strictly inside one, are refused, since updating the field would undo the edit; edit the field's target
  instead. A range holding a whole result, such as "Section 1.3 below" where "1.3" is a `REF` result,
  removes the field and is accepted. A `HYPERLINK` field's result is ordinary link text.
- **Splits and joins.** A split or join doesn't change what a selector sees. Text is selected through the
  uploaded paragraph that held it: the original locator of a split paragraph, or the merged-in
  paragraph's own locator.

A selector that matches nothing, or more than once without `occurrence`, is refused naming the count and,
when the text is elsewhere, the paragraphs holding it: `edit at index 0 (replace): "Preferred" matches 3
times in paragraph 8; give prefix or suffix so it matches once, or occurrence (1 to 3, or "all")`.

**Signature lines.** A signature line is usually a label, a plain tab to where the line starts, and an
underlined tab drawing the line to the next tab stop (`"Name:"`, tab, underlined tab). Insert the value
after the label's tab, `"at": {"after": {"match": "Name:\t"}}`: it takes the plain tab's formatting and the
underlined tab still draws the rest of the line. To have the value replace the line, replace the
underlined tab: `"text": {"match": "\t", "prefix": "Name:\t"}`. The new text takes the tab's formatting,
underlined; a run with `"format": {"underline": false}` gives plain text.

## Content

New text is always `content`: in `insert`, `replace`, `add_comment`, `reply_comment` and
`add_header_footer`, in note runs, in table cells and in `new_paragraphs` items. `content` is a string or an
array of runs, in the listing's shapes.

| Run | Inserts |
|---|---|
| `{"text": "…"}` | text |
| `{"type": "tab"}` | a tab |
| `{"type": "break", "kind": "line" \| "page" \| "column"}` | a break; page and column breaks only in the body and its tables |
| `{"type": "field", "code": "PAGE"}` | a field ([Field and cross-reference runs](#field-and-cross-reference-runs)) |
| `{"type": "cross_reference", "to": locator, "show": "text" \| "number" \| "page" \| "above_below"}` | a reference to a paragraph |
| `{"type": "footnote" \| "endnote", "content": …}` or `{…, "new_paragraphs": [{"content": …}]}` | a note, with one paragraph or several ([Note runs](#note-runs)) |
| `{"type": "hyperlink", "url": "…", "content": …}` | new linked text |
| `{"type": "picture", "source": {"file": "…"} \| {"data": "…"}, "width"?, "height"?, "alt_text"?}` | a picture in line with the text ([Pictures](#pictures)) |

- Any run can carry `format` ([Property values](#property-values)). A note run can carry a `ref`.
- In a `content` string or a text run, `\t` becomes a tab run and `\n` a line break. New paragraphs come
  only from `insert_paragraphs`, `split_paragraph` and the `new_paragraphs` forms.
- A table cell's value is `content` (one paragraph) or `{"new_paragraphs": […]}`, whose items are
  `insert_paragraphs` items.
- A comment's `content` (`add_comment`, `reply_comment`) is one paragraph: a string, or text, tab and
  line-break runs. Other runs are refused.
- A note run's content holds no picture or note. A hyperlink's content holds text, tab and break runs.
- `replace` requires non-empty `content`. Removing text is a `delete`.
- Plain new text takes the formatting of the text before it, or of the text after it at a paragraph's
  start, as typing does in Word; in an empty paragraph, the paragraph mark's. A `replace`'s plain
  `content` takes the formatting of the first character it replaces.
- Text an edit writes may not hold a control character other than tab, newline and carriage return, nor
  U+FFFE or U+FFFF, which a Word document cannot store.

## Pictures

A `picture` run inserts an image in line with the text, as a tracked insertion, wherever content goes:
`insert`, `replace`, `insert_paragraphs` items, table cells, `add_header_footer`, and paragraphs of a header,
footer or note. A comment and a note run hold no picture.

```json
{"type": "insert", "paragraphs": {"index": 0}, "at": "start",
 "content": [{"type": "picture", "source": {"file": "logo.png"}, "width": 144, "alt_text": "Acme logo"}]}
```

| Field | |
|---|---|
| `source` | `{"file": name}`, the file name of a `pictures` file uploaded with the request (multipart), or `{"data": base64}`, the image's bytes in base64, which count toward the 200 KB of edits. Exactly one. A request sent as JSON alone gives `data`. |
| `width`, `height` | Optional, in points, above 0 and at most 1584 (22 inches). With one, the other keeps the image's proportions; with neither, the picture takes the image's own size at its resolution (96 dpi when the file states none), made smaller to fit the text or cell width. An explicit size is not fitted. |
| `alt_text` | Optional: the picture's alternative text. |
| `format` | Optional character format of the picture's run. |

An image is a PNG, JPEG or GIF, its type read from its bytes rather than its file name. Linked (external)
images are not supported. Each image is at most 5 MB and 10,000 pixels on a side; a request inserts at most
20 different images, 25 MB in all (the same image used twice counts once), and uploads at most 20 files.
Each of these is refused at request time with `INVALID_EDIT`, together with the request's other refusals:
an unsupported or corrupt image, one past a limit, `source` with both or neither of `file` and `data`,
`data` that is not base64, a `file` no upload has, an upload no picture run names, two uploads with the
same file name, `width` or `height` at or below 0 or above 1584, and a picture in a comment or note run.

```bash
curl -sS -X POST "https://api.versionstory.com/v1/edit" -H "Authorization: Bearer vs_live_..." \
  -F file_id=3f2a… -F schema_version=1 -F pictures=@logo.png \
  -F 'edits=[{"type": "insert", "paragraphs": {"index": 0}, "at": "start",
              "content": [{"type": "picture", "source": {"file": "logo.png"}}]}]'
```

A listed picture is deleted with `delete` and its run-form match:
`{"type": "delete", "paragraphs": {"index": 3}, "text": {"match": [{"type": "picture", "index": 1}]}}`.

## Naming rules

- **Reserved keys.** `type`, `text` and `at` are reserved among an edit's keys. Inside a run, `type` is the
  run's type and `text` its characters, as in the listing.
- **Property objects.** As a key, `format` always holds a character-format object. Paragraph properties
  nest under `paragraph_format` and page properties under `page_setup`, the same objects the listing
  returns.
- **Field names.** Styles are named by `style_id`. `insert_paragraphs` takes `new_paragraphs`, because
  `paragraphs` is a locator field.
- **Operation names.** `insert_*` places new content at a position, and `add_*` attaches something to
  existing content. `delete*` removes content, and `remove_*` unwraps something and keeps its content.
- **Enums.** Every enum value is snake_case: `lower_roman`, `next_page`, `at_least`.

## Property values

For every property in `format`, `paragraph_format` and `page_setup`:

| Value | Effect |
|---|---|
| a value | sets the property |
| `false` | turns it off, even where the style turns it on |
| `null` | removes the direct setting, so the style decides |
| key omitted | leaves the property alone |

- **Toggles.** `bold`, `italic`, `small_caps`, `keep_with_next` and the like take `true`, `false` or `null`.
- **Enums with an off state.** `underline`, `highlight` and `strike` take an enum value, `false` for off, or
  `null`. `true` means `"single"` for `underline` and `strike`; it is refused for `highlight`.
- **Everything else.** Other properties take a value or `null`.
- **Page setup.** A section has no style for `null` to fall back to, so in `page_setup` only
  `different_first_page` and `page_numbering` (and its fields) take `null`.
- **Nested objects merge field by field.**
  - For example, `{"indent": {"left": 108}}` keeps the paragraph's other direct indent values.
  - Setting `first_line` removes `hanging`, and setting `hanging` removes `first_line`.
  - `null` for a whole object (`"indent": null`) removes all of its direct values.
  - The same merge applies to `margins`, `spacing` (which holds `before`, `after` and `line_spacing`),
    `page_numbering` and `settings`.
  - `line_spacing` is one value in one of three forms (`{"multiple": 1.15}`, `{"exact": 14}` or
    `{"at_least": 14}`), so it is replaced whole.
- **Whole paragraphs.** A `format` without `text` formats the paragraph mark too, as selecting a whole
  paragraph in Word does.
- **Removing everywhere.** To "remove X everywhere", send `false`. `null` only removes direct formatting,
  so a heading that is bold through its style would stay bold.
- **Units.** Indents, spacing, margins, paper size and `font_size` are in points.

## How a request is applied

Three rules:

1. **Addresses mean the document as you read it.** Every locator and selector refers to the uploaded
   document, the one the listing described, whatever earlier edits in the request have done.
2. **Edits apply in request order.** When two edits set the same property of the same text or paragraph,
   the later one wins.
3. **An edit whose target an earlier edit removed or rewrote is refused**, and so is the whole request.

The result is the same as sending each edit as its own request in turn, except that every edit's addresses
come from the one listing the agent read. The request is all or nothing, and revision decisions go alone
(see [Editing documents](/developers/rest/edit#how-a-request-is-applied)).

### Addresses

- **Indexes don't shift.** If edit 1 inserts a paragraph after paragraph 5, edit 2's `{"index": 10}` still
  means the paragraph the listing called 10, now eleventh in the document. An `id` names the same thing it named
  in the listing, wherever earlier edits moved it.
- **Table rows and columns don't shift.** `{"table": {"index": 1}, "index": 3}` is the listing's row 3
  (or grid column 3) whatever earlier edits inserted. A new row's or column's `cells` count the table as
  the earlier edits left it ([`insert_table_row`](#inserttablerow-and-deletetablerow)).
- **Positions don't shift.** A selector matches the uploaded text of the paragraphs it names. Text that
  earlier edits inserted is never matched; text they removed can't be matched (rule 3).
- **Wide forms.** `"all"`, `{"all": …}`, ranges and arrays name uploaded paragraphs. Paragraphs an earlier
  edit removed are skipped.
- **Refs name what an earlier edit created.** A ref used before the edit that creates it is refused. A new
  paragraph can be named by ref for paragraph-level edits: `set_paragraph_style`, `set_paragraph_format`,
  `set_numbering`, `format` without `text`, `add_comment` without `text`, `insert` at `"start"` or `"end"`,
  and as a `destination` or cross-reference `to`. Its text can't be selected, since it isn't in the
  listing; give it its formatting in the creating edit, or format the whole paragraph.
- **Moved paragraphs.** A locator for a paragraph that an earlier edit moved still names it, in its new
  place. A destination at it places content next to its new place.
- **Moves and other edits to the same paragraphs.** A `move_paragraphs` can come before or after
  edits to the paragraphs it moves (`replace`, `format`, `set_paragraph_style`, a comment, a
  hyperlink, ...); the result is the same either way, with the edits' changes inside the moved
  paragraphs.
- **Split paragraphs.** The original locator names both halves for paragraph-level edits. Its text is
  selected through the original locator. The second half can also be named by the split's ref.
- **Joined paragraphs.** A paragraph that an earlier `join_paragraphs` merged away can't be named for
  paragraph-level edits. Its text is still selected through its own locator, since joining keeps the text.
- **Deleted paragraphs as destinations.** A destination at a paragraph an earlier edit deleted places the
  content where that paragraph was.
- **`style_template`** styles are imported before the first edit.
- **Field results** are computed after the last edit, so a new table of contents or cross-reference
  reflects the edited document. `refresh_fields` does the same for existing fields. Results that need page
  layout are those the field tables below say the server can compute; any others are marked for Word to
  update on open.
- **Cross-references stay current.** After the last edit, every `REF` field in the document (body, headers,
  footers and notes) is recomputed; see [Cross-references after edits](#cross-references-after-edits).

### Order

- **Later wins.** "Unbold everything" then "bold Purchase Price" leaves only Purchase Price bold. Listed
  the other way round, everything ends up unbolded.
- **Lists and indents.** The indent a list level brings never overrides a direct `indent`, in either order,
  as in Word.
- **Same position.** Content inserted at the same point appears in request order, across operation types.
  - A `replace` puts its new text at the start of its range, so an `insert` at that point and the
    replacement come out in request order.
  - An insert at the end of a replaced or deleted range goes after it.
  - Two destinations are the same point only when they name the same paragraph or table and position.
    Content placed after one paragraph or table comes before content placed before the next one.
  - Content placed after the paragraph holding a new section break goes into the new section.
- **Comments.** Comments added to the same range, and replies to one thread, appear in request order. A
  comment's range follows its text, so a later `replace` inside it leaves the deleted and the new text
  inside the comment.
- **Revision decisions** apply in request order too, and the later decision wins for a revision. "Reject
  everything, then accept author A's" is `reject_revision` with `"all"`, then `accept_revision` with
  `{"author": "A"}`.

### Positions

- **Points.** An insertion point is a position in the uploaded text. `"start"` is before everything in the
  paragraph, and `"end"` after everything, hidden content included.
- **Hidden content.** Matching skips note references, comment anchors, bookmarks and tracked deletions, so
  a point next to them needs a side.
  - A point after a match sits immediately after its last character, before any hidden content.
  - A point before a match sits immediately before its first character, after any hidden content.
  - In a paragraph ending in a footnote mark, after the last word and `"end"` are different points.
- **Formatting of plain new text.** See [Content](#content).
- **Clearing never deletes characters.** A leading tab is removed with a `delete`.
- **New table cells** copy the paragraph and run formatting of a neighbouring cell. A new row copies the row it
  is placed beside (its destination `row`), and a new column copies its destination `column`.

### Refused

Every refusal in a request is reported together, naming the edits involved.

**Rule 3: the target is gone or rewritten.**

| An edit that… | after an earlier edit… |
|---|---|
| selects text (`text` or an `at` anchor; `prefix` and `suffix` excluded) overlapping characters | replaced or deleted them |
| has an insertion point strictly inside a range | replaced or deleted that range |
| names a paragraph for a paragraph-level edit, or as a destination | merged it away with `join_paragraphs`, or deleted it (except as a destination, above) |
| names a table, row, column, section or note | deleted it, or removed the section's break |
| has an insertion point at a split point, or a `replace` or `delete` crossing one | split the paragraph there |
| splits inside a range | replaced or deleted that range |
| names a header or footer through a section that now shows a different part | added a header or footer with `add_header_footer` (name the new part by its ref) |

`prefix` and `suffix` only check context, so they may overlap text an earlier edit changed.

**Refused in any order.**

| Combination | Why |
|---|---|
| A `replace`, `delete`, `insert` or `format` inside a field result | Updating the field would undo it. Edit the field's target instead. |
| Two `add_hyperlink` ranges that overlap | Word can't nest hyperlinks. |
| A `move_paragraphs` whose destination is inside the paragraphs it moves | The destination moves with them. |
| A row insert inside a vertically merged span, or a column insert or delete through a cell spanning several grid columns | The grid can't keep or split the merge. |
| Selecting text in a paragraph this request creates | It isn't in the listing. Describe it in the creating edit. |
| A `ref` on something an edit would create more than once | It couldn't name one thing. |

## Text

### `insert`

`insert` puts `content` at `at` in each named paragraph: once per paragraph with `"start"` or `"end"`,
once per match with a selector. Inserted content is a tracked insertion (`w:ins`); accepting keeps it and
rejecting removes it.

```json
{"type": "insert", "paragraphs": {"index": 304}, "at": {"after": {"match": "Name:\t"}}, "content": "Jordan Bryan"}
{"type": "insert", "paragraphs": {"index": 27}, "at": "start", "content": "2026"}
{"type": "insert", "paragraphs": {"index": 31}, "at": {"after": {"match": "Signature:"}},
 "content": [{"type": "tab", "format": {"underline": true}}, {"type": "break", "kind": "line"},
             {"text": "Name and title", "format": {"bold": true}}]}
{"type": "insert", "paragraphs": {"index": 9}, "at": {"after": {"match": "See "}},
 "content": [{"type": "hyperlink", "url": "https://example.com/schedule-a", "content": "Schedule A"}]}
```

To fill an empty paragraph, insert at `"start"`. A break is a run holding `w:br` (a line break has no
type; `w:type="page"` or `"column"`) and a tab one holding `w:tab`, inside the insertion. A `hyperlink`
run is written as a `HYPERLINK` field, the form in which Word can track a link, in one `w:ins`.

### Page breaks

A page break is a `break` run of `kind` `page`. Insert one with `insert`, and remove one with `delete`
whose `match` is that run (with `occurrence` when the paragraph holds several). Each is tracked: the
break's run in `w:ins`, or in `w:del` split from any text sharing it. A paragraph that starts a new page
through `page_break_before` holds no break: [`set_paragraph_format`](#setparagraphformat) with
`"page_break_before": false` removes the property. Turning a page break into a section break that
restarts page numbering is three edits:

```json
[{"type": "delete", "paragraphs": {"index": 129}, "text": {"match": [{"type": "break", "kind": "page"}]}},
 {"type": "insert_section_break", "paragraphs": {"index": 129}, "section_type": "next_page", "ref": "schedule"},
 {"type": "set_page_setup", "section": {"ref": "schedule"}, "page_setup": {"page_numbering": {"start": 1}}}]
```

Inserting one:

```json
{"type": "insert", "paragraphs": {"index": 42}, "at": "start", "content": [{"type": "break", "kind": "page"}]}
```

A page or column break goes in the body and its tables only: Word offers neither in a header, footer,
note or text box.

### `replace` and `delete`

`replace` deletes the selected text and inserts `content` (required, non-empty) at the start of its
range; `delete` deletes it. Deleted text is a tracked deletion (`w:del`), new text a tracked insertion.

```json
{"type": "replace", "paragraphs": {"index": 8}, "text": {"match": "Preferred", "prefix": "other Series A "}, "content": "Common"}
{"type": "replace", "paragraphs": "all", "text": {"match": "Acme Ltd", "occurrence": "all"}, "content": "Acme Holdings Ltd"}
{"type": "delete", "paragraphs": {"index": 71}, "text": {"match": "[", "occurrence": "all"}}
```

A `replace` or `delete` whose text isn't in the named paragraphs is refused, naming the paragraphs where
the text is. A page break is removed with `delete`; `replace` can't select one.

### `format`

`format` changes the character formatting of the selected text, or of whole paragraphs, marks
included, without `text`. The text is split into runs of its own, each recording its previous formatting
in a `w:rPrChange`, a tracked formatting change: the redline shows "Formatted" changes and the clean copy
keeps the new formatting.

```json
{"type": "format", "paragraphs": {"index": 12}, "text": {"match": "Effective Date"}, "format": {"bold": true, "highlight": "yellow"}}
{"type": "format", "paragraphs": "all", "format": {"bold": false}}
```

| `format` property | Values |
|---|---|
| `bold`, `italic`, `small_caps`, `all_caps`, `superscript`, `subscript` | `true`, `false` or `null` |
| `underline` | `true` (single), `single`, `double`, `thick`, `dotted`, `dash`, `wave`, `words`, `false` or `null` |
| `strike` | `true` (single), `single`, `double`, `false` or `null` |
| `highlight` | a Word highlight color (`yellow`, `green`, `cyan`, `magenta`, `blue`, `red`, `black`, `white`, `dark_blue`, `dark_cyan`, `dark_green`, `dark_magenta`, `dark_red`, `dark_yellow`, `dark_gray`, `light_gray`), `false` or `null` |
| `color` | six hex digits (`1F3864`), or `null` |
| `font_size` | points, in halves (`10.5`), or `null` |
| `font` | a font family name, or `null` |

At least one property is required, and `superscript` and `subscript` cannot both be `true`. The listing
reads formatting back as this object, which an edit takes unchanged: each text segment's `format` holds the
properties above that the text has through its styles, in these values (`"strike": "double"` for a double
strike); `color`, `font_size` and `font` appear where they differ from the document's default text formatting.
An underline style no edit sets, such as a dot-dash one, is left out.

## Paragraphs

### `insert_paragraphs`

`insert_paragraphs` places `new_paragraphs` at a destination, each item a Word paragraph in array order
and a tracked insertion of its text and paragraph mark.

```json
{"type": "insert_paragraphs", "destination": {"paragraph": {"index": 24}, "position": "after"},
 "style_id": "Normal", "list": {"new": "bulleted"}, "paragraph_format": {"indent": {"left": 108, "hanging": 18}},
 "new_paragraphs": [{"content": "Constitutional Amendments.", "style_id": "Heading3", "list": null},
                    {"content": "First Amendment (1791): freedom of religion and speech"},
                    {"content": [{"text": "Second Amendment", "format": {"italic": true}}, {"text": " (1791)"}], "ref": "second"}]}
```

- **Items.** Each takes `content` (omitted or `""`, an empty paragraph) and, optionally, `style_id`,
  `paragraph_format`, `list`, `list_level` (1 to 9) and `ref`.
- **Operation-level values.** `style_id`, `paragraph_format`, `list` and `list_level` on the edit apply to
  items that don't set their own; an item's `paragraph_format` merges over the operation's. With no style
  from either, the document's default paragraph style applies.
- **`list`.** `{"new": "bulleted" | "numbered"}` on the edit makes its items one new list; on an item it
  starts a list of its own. `{"same_as": locator}` joins an existing list. An item's `"list": null`
  leaves it out of the operation's list, so its style's numbering, if any, applies; `false` gives it no
  numbering. `list_level` defaults to the operation's, else the `same_as` paragraph's, else 1. A paragraph
  whose style already numbers it in that list at that level, as a heading style numbers a heading, joins
  it through its style, with no numbering of its own, so it keeps the style's indents as the list's other
  paragraphs do; otherwise the paragraph names the list and level itself and takes the level's indents.
- **Typography.** A new paragraph in the style of the destination paragraph continues that
  paragraph's character formatting, as pressing Enter in Word does: its text takes the destination's
  paragraph mark formatting, with each run's `format` over it. A paragraph in another style takes the
  formatting most of the text of the nearest paragraph of its own style has (in the same part and at the same
  level, body or table cell, before the destination, else after it), so it reads as the document's other
  paragraphs of that style do where a template sets their size or font on the text rather than in the
  style; with none, it starts from its style alone. A new paragraph's mark, which sets the size and font
  of a list number, takes the formatting its text shares; an empty paragraph's mark takes the one it
  continued.

A destination is a paragraph directly in the body or a table cell. A missing paragraph style fails the
job with `INVALID_EDIT`. For Markdown input, parse it in the client and send each paragraph's text,
runs and style; `.md` and `.txt` uploads are unsupported, so start from a blank DOCX.

### `delete_paragraphs`

`delete_paragraphs` deletes whole paragraphs as tracked deletions of their text and paragraph marks, so
accepting it leaves the following paragraph exactly as it was. A range covering a whole table deletes the
table's rows as tracked row deletions.

```json
{"type": "delete_paragraphs", "paragraphs": [{"from": {"index": 9}, "through": {"index": 11}}, {"index": 171}]}
```

The last paragraph of the document, a cell, a header, a footer or a note holds the mark Word requires
there, so deleting it deletes its text and the mark of the paragraph before it instead, and that mark's
properties pass to the last one as tracked changes: accepting leaves the paragraph before it last, as it
was, and rejecting restores both.

Refused: a range covering part of a table or more than one of its cells; every paragraph of a cell
(delete the row, column or table); the only paragraph of the document, a header, a footer or a note, and
a last paragraph after a table or content control, or after a paragraph that ends a section (delete its
text with `delete`); a paragraph that ends a section; paragraphs that are all that keeps
two tables apart, since Word joins tables that touch; and a paragraph holding a bookmark that a `REF` or
`PAGEREF` field in another paragraph points to, unless the request deletes that paragraph too or a `replace` or
`delete` removes the field (the refusal names the referencing paragraphs; see
[Cross-references after edits](#cross-references-after-edits)).

### `move_paragraphs`

`move_paragraphs` moves paragraphs, marks included, to a destination as a Word tracked move. A range
covering whole tables moves them too: Word can't track a table move, so the table's rows are deleted where
they were and an inserted copy, keeping the ids, goes to the destination.

```json
{"type": "move_paragraphs", "paragraphs": {"from": {"index": 9}, "through": {"index": 11}},
 "destination": {"paragraph": {"index": 19}, "position": "after"}}
```

At the old place a copy of each paragraph stays as moved-from content (`w:moveFrom`, between
`w:moveFromRangeStart` and `w:moveFromRangeEnd`); at the destination the paragraphs themselves, keeping
their ids, are moved-to content (`w:moveTo`), the two ranges sharing one `w:name`. Accepting leaves the
paragraphs at the destination only; rejecting restores them, and the copies have new ids. Deciding either
half decides the whole move. A moved footnote or endnote reference refers to a new note holding the
note's paragraphs, marked inserted, while the old note's text is marked deleted; deciding the move decides
them, so each note is in the document once either way. Bookmark and comment anchors inside the moved
paragraphs move with them; a bookmark reaching outside them that no field or hyperlink names keeps its
inside end at the old place.

`pending_changes` is optional: `"accept"` or `"reject"` settles the uploaded document's tracked changes
inside the moved paragraphs (and their notes) before the move, so the move carries clean content.
`"accept"` keeps insertions and drops deletions; `"reject"` drops insertions and restores deletions. Without
it, a move of paragraphs that hold tracked changes is refused, and the refusal lists those changes. It
settles only the changes inside the moved paragraphs; changes elsewhere are left as they are.

```json
{"type": "move_paragraphs", "paragraphs": {"index": 9}, "pending_changes": "accept",
 "destination": {"paragraph": {"index": 19}, "position": "after"}}
```

A move does not depend on where it sits in the request relative to other edits to the same paragraphs:
`replace`, `format`, `set_paragraph_style`, `add_comment` and the like give the same document before or
after the move.

Refused: a destination among the moved paragraphs, where they already are, or after the last paragraph
of the body or a cell; paragraphs outside the body and its tables; a range covering part of a table;
moving the last paragraph of the body or a cell, a paragraph that ends a section, or paragraphs that keep
two tables apart; moved paragraphs or their notes holding tracked changes (unless `pending_changes` is given), pictures, embedded objects,
content controls, permission ranges, or a field that fetches or runs content (`INCLUDETEXT`, `INCLUDE`,
`INCLUDEPICTURE`, `DDE`, `DDEAUTO`, `IMPORT`, `LINK`, `MACROBUTTON`, `DATABASE`, `RD`); part of a field
or comment reaching outside them; and a moved table holding tracked changes, notes, comments or
bookmarks.

### `split_paragraph`

`split_paragraph` ends a paragraph at one point with a new paragraph mark, as pressing Enter does. `at`
is a selector matching once; `"start"` and `"end"` are refused (use `insert_paragraphs`). `ref` names
the second half.

```json
{"type": "split_paragraph", "paragraphs": {"index": 12}, "at": {"after": {"match": "as follows:"}}, "ref": "rest"}
```

The split is tracked as an inserted paragraph mark (`w:pPr/w:rPr/w:ins`) ending the first half, which
keeps the paragraph's id; the second half has a new id. Both keep the paragraph's properties, and the
second half takes the original mark with what it carries (a section break, the mark's formatting).
Accepting keeps two paragraphs; rejecting merges them back. The original locator names both halves for
paragraph-level edits. A point inside a hyperlink, a field, a content control or a tracked change fails
the job with `INVALID_EDIT`.

### `join_paragraphs`

`join_paragraphs` merges a range of paragraphs into its first, as deleting the pilcrows does in Word.

```json
{"type": "join_paragraphs", "paragraphs": {"from": {"index": 40}, "through": {"index": 41}}}
```

Each join is tracked as a deleted paragraph mark (`w:pPr/w:rPr/w:del`). The joined paragraph keeps the
first paragraph's properties: where a following paragraph's differ, they change as a tracked property
change (`w:pPrChange`). Accepting leaves one paragraph; rejecting restores them. Refused: a range of
fewer than two paragraphs, across table cells or partly in a table; a paragraph ending a section, one
whose mark is already deleted, or one followed by a table.

### `set_paragraph_style`

`set_paragraph_style` gives paragraphs a paragraph style of the document or of the `style_template`.
`clear` takes any of `typography`, `numbering` and `paragraph`, or `"all"`.

```json
{"type": "set_paragraph_style", "paragraphs": [{"index": 3}, {"from": {"index": 5}, "through": {"index": 9}}],
 "style_id": "Heading3", "clear": "all"}
```

- `typography`: direct font family (theme fonts included), size, colour and character spacing. Bold,
  italics, underline, caps, highlight and character styles are kept.
- `numbering`: direct list numbering.
- `paragraph`: spacing, indentation, alignment, tab stops and outline level. Pagination and section
  boundaries are kept.

Anything outside those three categories, such as borders and shading, is kept.

The style and the cleared paragraph properties are one tracked paragraph property change (`w:pPrChange`),
and cleared typography a tracked formatting change (`w:rPrChange`). Clearing never deletes text: a
leading tab is removed with a `delete`. A style the paragraph already has, with nothing to clear, records nothing. A style the document
and `style_template` lack fails the job with `INVALID_EDIT`.

### `set_paragraph_format`

`set_paragraph_format` sets alignment, indents, spacing and pagination.

```json
{"type": "set_paragraph_format", "paragraphs": [{"index": 12}, {"index": 13}],
 "paragraph_format": {"alignment": "justify", "indent": {"left": 36, "first_line": 18},
                      "spacing": {"before": 0, "after": 6, "line_spacing": {"multiple": 1.15}}, "keep_with_next": true}}
```

| Property | Values |
|---|---|
| `alignment` | `left`, `center`, `right`, `justify`, or `null` |
| `indent` | `left` and `right` (points, -1584 to 1584), and `first_line` or `hanging` (0 to 1584) |
| `spacing` | `before` and `after` (points, 0 to 1584), `line_spacing` |
| `keep_with_next`, `keep_lines_together`, `page_break_before`, `widow_control` | `true`, `false` or `null` |
| `borders` | by side (`top`, `left`, `bottom`, `right`, `between`, `bar`), each a [border](#settableformat-and-setcellformat) or `null` |

Measures are rounded to the twentieth of a point and replace the paragraph's own value in every form
Word reads. The change is tracked: each paragraph's previous properties go into a `w:pPrChange`, so
rejecting restores them; a format the paragraph already has records nothing. Header, footer, note and
table-cell paragraphs are formatted the same way. Each listed paragraph's `paragraph_format` is this object,
which this edit takes unchanged, where the paragraph sets a property or differs from the document's default.

### `set_style_format`

`set_style_format` sets a paragraph style's own formatting, which every paragraph in the style, and in the
styles based on it, takes: keep with next on the section heading style keeps each heading on the page of
the clause below it.

```json
{"type": "set_style_format", "style_id": "Heading3", "paragraph_format": {"keep_with_next": true}}
```

`paragraph_format` takes `set_paragraph_format`'s properties; `null` removes the style's own setting, so
it inherits the setting of the style it is based on. Paragraphs that set a property themselves keep their
own value. The change is applied to the style definition untracked, as Word applies a modified style
(see [Tracked and untracked](#tracked-and-untracked)). The listing's `paragraph_styles` gives each
paragraph style in use, and the styles they are based on, with its `paragraph_format` in these terms. A
style the document does not define, or one that is not a paragraph style, fails the job with
`INVALID_EDIT`.

### `rename_style`

`rename_style` gives a style a new name, as Word's Modify Style › Name does. Word matches styles by name
when text comes in from another document, through an `INCLUDETEXT` field or a paste: a style whose name the
other document also uses takes that document's definition there, so a DPA's "heading 2" included in an
agreement whose "heading 2" is centred and bold becomes centred and bold. Under a name of its own, the
style comes in with its own definition and the text keeps its formatting wherever it goes.

```json
{"type": "rename_style", "style_id": "Heading2", "name": "DPA Heading 2"}
```

The style's new `style_id` is the name's letters and digits (`DPAHeading2`, numbered on when another style
has it), and everything that named the old id follows: the paragraphs, runs and tables in the style in every
part, including the earlier properties tracked changes hold, the styles based on it, following it or linked
to it, and the list levels it numbers. Its linked character style, named "Heading 2 Char" as Word names one,
becomes "DPA Heading 2 Char". A built-in heading's outline level, which Word gives "heading 1" to "heading
9" by name, is written into the style, so the renamed heading stays in the navigation pane and tables of
contents. Later edits in the request name the style by its new id. The rename is applied untracked, as Word
applies a modified style (see [Tracked and untracked](#tracked-and-untracked)).

Refused with `INVALID_EDIT`: a style the document does not define; the document's default style of its type
(Normal), which text without a style is in whatever its name; a name another style or one of Word's
built-in styles has, whatever its case; a name with a comma, which Word reads as separating a style's names;
and a style a `STYLEREF` or table of contents field names, since it would stop finding the style.

### `set_numbering`

`set_numbering` changes list numbering by `action`. `list_level` counts from 1, as the listing's `list_level` does.

```json
{"type": "set_numbering", "paragraphs": {"from": {"index": 20}, "through": {"index": 22}}, "action": "apply", "list": {"same_as": {"index": 19}}}
{"type": "set_numbering", "paragraphs": [{"index": 5}, {"index": 6}], "action": "apply", "list": {"new": "bulleted"}, "list_level": 1}
{"type": "set_numbering", "paragraphs": {"index": 21}, "action": "set_level", "list_level": 2}
{"type": "set_numbering", "paragraphs": {"index": 30}, "action": "restart", "start": 1}
{"type": "set_numbering", "paragraphs": {"index": 31}, "action": "continue"}
{"type": "set_numbering", "paragraphs": {"index": 40}, "action": "remove"}
{"type": "set_numbering", "paragraphs": {"from": {"index": 24}, "through": {"index": 41}},
 "action": "set_number_format", "list_level": 3, "number_format": "lower_roman", "pattern": "(%3)"}
```

- **`apply`.** The paragraphs become one list instance: a `{"new": "bulleted" | "numbered"}` list, with
  Word's own nine levels for its Bullets or Numbering button, or the list of a `same_as` paragraph. Each
  keeps its level (1 if it had none) unless `list_level` is given.
- **`restart` and `continue`.** At the first named paragraph, `restart` numbers from `start`, and
  `continue` resumes the previous list of the same definition, as Word's "Continue numbering" does. The
  items after it in its list, up to the next item at a higher level, go with it.
- **`set_number_format`.** Changes one level's `number_format` (`decimal`, `decimal_zero`,
  `lower_roman`, `upper_roman`, `lower_letter`, `upper_letter`, `ordinal`, `cardinal_text`,
  `ordinal_text`, `bullet`, `none`) and optional `pattern` (`(%3)` shows level 3's number in brackets)
  for the named paragraphs at that level. A run is a maximal sequence of named paragraphs at that level
  with only deeper-level paragraphs between them; a paragraph at that level that isn't named, or any
  paragraph at a parent level, ends the run. Each run gets its own list instance, with that level
  overridden, starting at the run's first current number, so numbers keep their values and change only
  their format; the deeper-level paragraphs between and directly after them move into it, so numbering
  still restarts under each item. A pattern showing another level's number is refused, and so, when the job runs, is a
  deeper level moving into the instance whose pattern shows a level above the changed one (level 4
  `"%2.%4"` when level 3 changes).

Each paragraph's numbering change is a tracked paragraph property change (`w:pPrChange`); removing
numbering a style gives writes `w:numId` 0, as Word does. The list instances and definitions a new list,
a restart or a format change needs are added untracked: Word cannot track numbering definitions. The
listing shows each numbered paragraph's `label` and `list_level`, and `list_styles`. Refused: a
paragraph that isn't numbered for `set_level`, `restart`, `continue` or `remove`, a level the list
doesn't define, and a `continue` with no earlier list of the same definition.

## Sections

A section break is the end of a paragraph that carries the properties of the section it ends: the
listing shows them as that paragraph's `section`, and the last section's as `final_section`, each with its
`section_type` and its `page_setup` in `set_page_setup`'s terms. OOXML keeps
one record of a section's earlier properties, so a section edit that must record a change on a section
whose properties already carry a tracked change is refused (accept or reject it first).

### `insert_section_break`

`insert_section_break` ends a section at each named paragraph, which must be directly in the body, not
already end a section and not be the body's last paragraph. A paragraph an earlier edit of the request
inserts may be named by its `ref`, so a request can add content and start a new section after it (an
exhibit or schedule on a new page). `section_type` is how the new section starts:
`next_page`, `continuous`, `even_page` or `odd_page`. `ref` names the section after the break; the
original index still names the one before it.

```json
{"type": "insert_section_break", "paragraphs": {"index": 129}, "section_type": "next_page", "ref": "schedule"}
```

Both sections keep the original's page setup, columns, headers and footers; the new one starts as
`section_type` says. The break is tracked as Word tracks an inserted section break: the paragraph ends
with an inserted paragraph mark carrying the new section's properties, and its original mark, tracked as
deleted, ends an empty paragraph after it; a change to how the following section starts is a
`w:sectPrChange`. All of it is one revision. A paragraph the request inserts carries the section's
properties on its own mark, part of that paragraph's insertion: rejecting it removes the paragraph and
its break together.

### `remove_section_break`

`remove_section_break` removes the break that ends `section`, joining its content to the next section.
`keep` says whose properties the merged section has:

- `"earlier"` (the default): `section`'s own headers, footers (every type), page setup, columns, page
  numbering, different first page and section type. The next section's settings as they were are a tracked
  `w:sectPrChange`; its header and footer references change untracked, since a `w:sectPrChange` cannot
  hold them, so rejecting restores the break and the next section's page setup but leaves that section
  with `section`'s headers and footers.
- `"later"`: the next section's properties, headers and footers, as Word does. The merged section starts
  as the removed one started (its section type and page-number restart), a tracked `w:sectPrChange`.

```json
{"type": "remove_section_break", "section": {"index": 1}}
```

The break is tracked as a deleted paragraph mark carrying the removed section's properties; a paragraph
holding nothing but the break is deleted whole. All of it is one revision. Removing several breaks in one
request gives the same clean copy in either order. Refused: the last section, a break that is itself a tracked
insertion (reject that revision instead), and a paragraph mark already tracked as deleted or moved.

### `set_page_setup`

`set_page_setup` changes the page of `section`: a section locator, an array of them, or `"all"`.

```json
{"type": "set_page_setup", "section": "all",
 "page_setup": {"orientation": "landscape", "paper": "a4", "margins": {"left": 72, "right": 72, "top": 54},
                "different_first_page": true, "page_numbering": {"start": 1, "number_format": "lower_roman"}}}
```

| Field | |
|---|---|
| `orientation` | `portrait` or `landscape` |
| `paper` | `letter`, `legal` or `a4`; or `page_width` and `page_height` (points, 72 to 1584) instead |
| `margins` | points by side: `top`, `bottom`, `left`, `right`, `header`, `footer`, `gutter` |
| `different_first_page` | `true` gives the first page its own header and footer (`w:titlePg`) |
| `page_numbering` | `start` (0 to 32767) and `number_format` (`decimal`, `lower_roman`, `upper_roman`, `lower_letter`, `upper_letter`) |
| `columns` | `count` (1 to 45) columns of equal width, and `spacing` between them in points (Word's 36 when the section sets none); the listing's `page_setup` shows a section's columns, in these terms, when its text runs in more than one of equal width |

`null` is accepted only for `different_first_page` and `page_numbering` (and its fields), since a section
has no style to fall back to. The change is tracked: the section's earlier properties are kept in a `w:sectPrChange`, and
a setting the section already has records nothing. `different_first_page` alone leaves the first page
with whatever first-page header and footer the section has, usually none; add them with
[`add_header_footer`](#addheaderfooter). The listing prints each section's `page_setup` as this object, which
this edit takes unchanged.

### `add_header_footer`

`add_header_footer` gives a section a header or footer of its own: `part` names `kind` (`header` or
`footer`), `type` (`default` or `first`) and `section`. Its text is `content` (one paragraph) or
`new_paragraphs` (1 to 100, each `content`, `style_id` and `paragraph_format`); with neither, the part is
one empty paragraph, a blank header or footer, and an item without `content` is an empty paragraph. `ref`
names the new part.

```json
{"type": "add_header_footer", "part": {"kind": "footer", "type": "default", "section": {"index": 0}}, "ref": "ftr",
 "new_paragraphs": [{"content": [{"text": "Page "}, {"type": "field", "code": "PAGE"}, {"text": " of "},
                                 {"type": "field", "code": "NUMPAGES"}], "paragraph_format": {"alignment": "center"}}]}
{"type": "format", "paragraphs": {"part": {"ref": "ftr"}, "index": 0}, "format": {"font_size": 9}}
```

The content is tracked as Word tracks typing into a new header: every run is a tracked insertion and
every paragraph mark but the last an inserted one, so rejecting leaves it empty. The new part
(`header1.xml`, `footer1.xml`, …) and the section's reference to it are applied untracked: Word records
neither as a revision.

A section without its own reference of a kind and type shows the previous section's part (Word's "Link to
Previous"), and the two sections `insert_section_break` makes name the same parts, so one part can show in
several sections. Adding to a section whose part another section also shows gives that section alone a new
part; every other section keeps what it showed, the next section getting its own reference to the old part
when it was linked to this one. When the section showed no part of the kind and type, later sections linked
to it show the new one. A `first` part shows only while `different_first_page` is on, and adding one
doesn't turn that on. Refused: a section whose part of that kind and type no other section shows, which is
its own (edit its paragraphs), and an `even` part, since creating one needs the document-wide even-and-odd
setting. After it, a locator naming that header or footer through a section that now shows the new part
is refused; name the new part by its ref.

## Tables

The listing shows a table's `index`, `id` and `style_id`, each row's `index`, `id`, `header` flag and tracked
`change`, and each cell's `index`, `id`, `span` and `change`. Every table edit is tracked, and `accept_revision`
and `reject_revision` decide each row, cell, grid and text revision it makes. A cell value is `content`
(one paragraph; `\n` is a line break) or `{"new_paragraphs": [...]}`, whose items are
[`insert_paragraphs`](#insertparagraphs) items (`content`, `style_id`, `paragraph_format`, `list`,
`list_level`, `ref`).

### `insert_table`

`insert_table` places a table at a destination. `rows` holds each row's cell values, every row spanning the
same number of grid columns (at most 63); `header_row` (default `false`), when `true`, repeats the first row on
each page and gives it the table style's header-row formatting. `ref` names the table. A cell of several
paragraphs may give `span`, the grid columns it is merged across (`{"new_paragraphs": [...], "span": 2}`, as
the listing gives a merged cell's `span`; 1 when left out): it counts as that many columns of its row.
`insert_table_row` and `insert_table_column` copy the cells beside them, so their cells take no `span`.

```json
{"type": "insert_table", "destination": {"paragraph": {"index": 49}, "position": "after"}, "header_row": true,
 "rows": [["Class", "Shares authorized"], ["Common stock", "[__________]"],
          [{"new_paragraphs": [{"content": "Preferred stock"}, {"content": "Series A", "style_id": "Normal"}]}, "—"],
          [{"new_paragraphs": [{"content": "Total authorized"}], "span": 2}]]}
```

A cell value of `""` is an empty cell: a paragraph with no text. The same holds for the `cells` of
`insert_table_row` and `insert_table_column`. Everywhere else, empty text is refused.

Every row is a tracked row insertion (`w:trPr/w:ins`) and its text a tracked insertion, so rejecting
removes the table. The table has single borders like Word's Table Grid, and its columns share the text
width of the section (or of the cell, for a nested table). Where it would touch another table or end its
container, an empty paragraph with a tracked inserted mark is added beside it. Headers, footers and notes
hold tables too, tracked the same way.

### `delete_table`

`delete_table` deletes a table as tracked row deletions of every row and its text; accepting removes it.

```json
{"type": "delete_table", "table": {"index": 2}}
```

A row another author already deleted is left as it is.

### `insert_table_row` and `delete_table_row`

`insert_table_row` adds a row before or after `row`, with one cell value per cell of `row`, the row it copies,
as the listing shows them (a cell spanning two grid columns is one value); `ref` names
it. `delete_table_row` deletes a row as a tracked row deletion (`w:trPr/w:del`) with its text deleted.

Rows and columns of one table can be inserted and deleted in one request, and each new row or column
counts its values against the table as the earlier edits of the request left it, the same as sending
the edits in turn. Locators still give the listing's row and column indexes.

- A row copies every cell of `row` as it is then: a column an earlier edit inserted gives it one more
  cell, and one more value.
- A column covers every row as it is then: a row an earlier edit inserted gets a cell in it, and one more
  value.
- A cell with a tracked deletion (`change` of type `deletion` in the listing, or a column an earlier edit
  deleted) is copied as an empty cell and takes no value. A row with a tracked deletion (or one an earlier
  edit deleted) gets an empty cell in a new column and takes no value.

```json
{"type": "insert_table_column", "destination": {"column": {"table": {"index": 1}, "index": 2}, "position": "after"},
 "cells": ["Integrated since", "March 15, 2021", "September 12, 2022"]}
{"type": "insert_table_row", "destination": {"row": {"table": {"index": 1}, "index": 2}, "position": "after"},
 "cells": ["Datadog, Inc.", "Sarah Mitchell", "Application performance monitoring", "June 3, 2024"]}
```

In the other order, the row takes three values and the column four.

```json
{"type": "insert_table_row", "destination": {"row": {"paragraph": {"index": 311}}, "position": "after"},
 "ref": "google", "cells": ["Google LLC", "Google Cloud Support", "AI-Enhanced PDF Conversion"]}
{"type": "delete_table_row", "row": {"table": {"index": 0}, "index": 3}}
{"type": "delete_table_row", "row": {"id": "6e127ae6b2e144759046e4f3f1fe01de"}}
```

The new row copies the cell layout of the `row` it is placed beside and formatting, without its revisions, vertical
merges or header flag (the `row` it is placed beside), and is a tracked row insertion with
its text tracked. Each new cell's text takes the run formatting carried by the most plain text in the
neighbouring cell, as text typed into a row Word inserts does. Refused: a row inside a vertically merged
span, and a cell count other than the number of cells in the row it copies (a merged cell counts once, a
deleted cell not at all).

### `insert_table_column` and `delete_table_column`

`insert_table_column` adds a grid column before or after `column`, with one cell value per cell of `column`,
the column it copies (one per row; a row whose cells stop short of the new column, or one with a tracked
deletion, takes none);
`delete_table_column` deletes a column.

```json
{"type": "insert_table_column", "destination": {"column": {"table": {"index": 1}, "index": 2}, "position": "after"},
 "cells": ["Q3", "12", "9"]}
{"type": "delete_table_column", "column": {"paragraph": {"index": 143}}}
```

Word does not record inserting or deleting a column as one tracked change; it displays and decides
tracked cell insertions and deletions, which is what these edits write. Each new cell is a tracked cell
insertion (`w:cellIns`) with its text a tracked insertion, formatted like the cell beside it, and the
table grid's earlier columns are kept in `w:tblGridChange`, which carries no author: a decision that
settles all of a table's cell insertions and deletions settles its grid change the same way.

The table keeps its width. The new column starts as wide as the column it is placed beside, then all the
columns, the new one included, shrink by the same proportion until the grid is as wide as it was. A
table 9630 twips wide with columns of 2790, 3397 and 3443 gets a fourth column after the third of 2537,
the others becoming 2055, 2502 and 2536. The table's own width (`w:tblW`, in twips, a percentage or auto)
is not changed. Each cell's width follows the columns it spans, as a tracked property change
(`w:tcPrChange`) that keeps its earlier width; rejecting restores the grid and the widths.

A deleted column's cells are tracked cell deletions (`w:cellDel`) with their text deleted; accepting gives
each cell's grid column and width to the cell before it, so the table keeps its width (a deleted first
column has no cell before it, and leaves its rows a column short). Refused: a column through a cell spanning several grid columns, a cell
count other than the column's cells, and a paragraph locator in a spanning cell, which names no one column.

### `set_table_format` and `set_cell_format`

`set_table_format` sets a table's borders; `set_cell_format` sets the borders of `cells`: one cell locator
(`{"row": {...}, "index": n}`, `{"id": "…"}` or `{"paragraph": locator}`), or `{"from": cell, "through": cell}`,
every cell of one table whose row lies between the two cells' rows and whose first grid column lies between the
grid columns they start at.

```json
{"type": "set_table_format", "table": {"index": 0},
 "table_format": {"borders": {"inside_horizontal": {"style": "single", "width": 0.5, "color": "auto"}, "left": null}}}
{"type": "set_cell_format", "cells": {"row": {"table": {"index": 0}, "index": 1}, "index": 3},
 "cell_format": {"borders": {"bottom": {"style": "dotted", "width": 0.5}}}}
{"type": "set_cell_format",
 "cells": {"from": {"row": {"table": {"index": 0}, "index": 2}, "index": 1},
           "through": {"row": {"table": {"index": 0}, "index": 2}, "index": 3}},
 "cell_format": {"borders": {"bottom": {"style": "none"}}}}
```

A table's sides are `top`, `left`, `bottom`, `right`, `inside_horizontal` (between rows) and
`inside_vertical` (between columns); a cell's are the same, plus `diagonal_down` and `diagonal_up`, and a
paragraph's (`set_paragraph_format`) are `top`, `left`, `bottom`, `right`, `between` and `bar`.

| Border field | Values |
|---|---|
| `style` | `none`, `single`, `thick`, `double`, `dotted`, `dashed`, `dot_dash`, `dot_dot_dash`, `triple`, `wave`, `double_wave`, `dash_small_gap`, `dash_dot_stroked`, `three_d_emboss`, `three_d_engrave`, `outset`, `inset` and the thin-thick gaps (`thin_thick_small_gap`, ... `thin_thick_thin_large_gap`) |
| `width` | points, 0.25 to 12 in eighths; 0.5 when left out |
| `color` | six hex digits, or `auto` (the default) |
| `theme_color` | a theme colour (`text1`, `accent1`, ...), which Word prefers to `color` |
| `space` | points between the border and the text, 0 to 31 |
| `shadow`, `frame` | `true` or `false` |

A side given a border is set to it; `null` removes the side's own setting, so the table's or style's
applies; a side left out is kept. Style `none` is an explicit absence (`w:val="nil"`): it keeps a border
the table, its style or a neighbouring cell would draw from being drawn, and takes no other field. Where
two cells meet, Word draws the border either sets, so removing a line between two rows means the upper
cells' `bottom` and the lower cells' `top`. The change is tracked: the table's previous properties go
into a `w:tblPrChange`, each cell's into a `w:tcPrChange`, so rejecting restores them; a format the table
or cell already has records nothing. The listing reports each table's `table_format`, each cell's
`cell_format` and each paragraph's `paragraph_format.borders`, the borders they set themselves, in these
terms. Refused: a cell no row has at that row and column, and a table this request creates.

## Fields and links

### Field and cross-reference runs

A `field` run inserts a document field by its `code`, Word's field code, as the listing's `fields` print
it; a `cross_reference` run inserts a field pointing to another paragraph, `to`.

```json
{"type": "insert", "paragraphs": {"part": {"kind": "footer", "type": "default", "section": {"index": 0}}, "index": 0},
 "at": {"after": {"match": "Page "}}, "content": [{"type": "field", "code": "PAGE \\* ROMAN"}]}
{"type": "insert", "paragraphs": {"index": 12}, "at": {"after": {"match": "see Section "}},
 "content": [{"type": "cross_reference", "to": {"index": 30}, "show": "number"}]}
```

| Field code | Result Version Story writes |
|---|---|
| `PAGE`, `NUMPAGES`, `SECTIONPAGES` | `1`, which Word replaces when it lays out the pages |
| `DATE` | the edit's date; Word shows the current date on open |
| `CREATEDATE`, `SAVEDATE` | the document's created or last-saved date |
| `TITLE`, `AUTHOR` | the document's title or author |
| `DOCPROPERTY` | the named property: a custom property, or `Title`, `Subject`, `Author`, `Keywords`, `Comments`, `Category`, `Company`, `Manager`, `LastSavedBy` |
| `FILENAME` | none; marked for Word to compute on open |
| `INCLUDETEXT "file" [bookmark]` | the run's `result`, such as `"[Exhibit A loads here: select all and press F9]"`, until the reader updates the field |

A field is one tracked insertion: begin, instruction, result and end inside a `w:ins`, in the formatting
of the text before it. `INCLUDETEXT` shows another Word file's text, or one bookmark of it, once the
reader updates the field in Word: its code names the file in quotes, as the reader's computer finds it,
and optionally a bookmark, with `\* MERGEFORMAT` the only switch. Version Story cannot read that file, so the
field shows the run's `result` (1 to 1000 characters; none, an empty result) and is never marked to update
on open; only an `INCLUDETEXT` run takes a `result`. No other field that fetches or runs content
(`INCLUDE`, `INCLUDEPICTURE`, `DDE`, `DDEAUTO`, `IMPORT`, `LINK`, `MACROBUTTON`, …) is offered, and
`refresh_fields` still refuses a document that holds one, `INCLUDETEXT` included.

```json
{"type": "insert_paragraphs", "destination": {"paragraph": {"index": 160}, "position": "after"},
 "new_paragraphs": [{"content": [{"type": "field", "code": "INCLUDETEXT \"/Templates/DPA.docx\" DPABody",
                                  "result": "[The DPA loads here. Select all and press F9.]"}]}]}
```

| `show` | Field | Result |
|---|---|---|
| `number` | `REF _Ref… \r \h` | the paragraph's list number, without trailing periods (`2.1`) |
| `text` | `REF _Ref… \h` | the paragraph's text |
| `page` | `PAGEREF _Ref… \h` | `1`, marked for Word to compute on open |
| `above_below` | `REF _Ref… \p \h` | "above" or "below", by where the paragraph is |

A cross-reference's result is the one its paragraph has after the last edit, with every revision
accepted. The paragraph pointed to needs a hidden `_Ref` bookmark around its text: an existing one is
reused, otherwise one is added, untracked, and stays if the cross-reference is rejected; the field is a
tracked insertion. `number` for a paragraph without a list number, and `text` for one without text, are
refused.

### `add_hyperlink`

`add_hyperlink` links existing text: the one match of `text`, or the whole paragraph without it. `url`
is an absolute `http`, `https` or `mailto` address.

```json
{"type": "add_hyperlink", "paragraphs": {"index": 4}, "text": {"match": "our website"}, "url": "https://example.com"}
```

The link is a `HYPERLINK` field, the form Word can track: its code is inserted before and after the text
(`w:ins`) and the text takes the Hyperlink character style as a tracked formatting change. Accepting
gives the link; rejecting restores the text. The Hyperlink style is added, untracked, when the document
has none. Refused: addresses that run code, reach local files or embed content (`javascript:`,
`vbscript:`, `file:`, `data:`), other schemes, relative and UNC addresses, and addresses with spaces,
quotes, backslashes or angle brackets (percent-encode them); text that is already a link (use
`set_hyperlink`); and two `add_hyperlink` ranges that overlap.

### `set_hyperlink` and `remove_hyperlink`

`set_hyperlink` gives a hyperlink a new `url`; `remove_hyperlink` unlinks it and keeps its text. With
`text`, the hyperlink holding the match; without it, the paragraph's only hyperlink.

```json
{"type": "set_hyperlink", "paragraphs": {"index": 4}, "text": {"match": "our website"}, "url": "https://example.com/new"}
{"type": "remove_hyperlink", "paragraphs": {"index": 4}}
```

Both are tracked: a new address deletes the old field code (`w:delInstrText` in `w:del`) and inserts the
new one; unlinking deletes the field code and removes the Hyperlink style as a tracked formatting change.
A link held as a `w:hyperlink` element or a simple field is first rewritten as the equivalent `HYPERLINK`
field. Refused when the selection covers no hyperlink, or more than one. The listing shows a link on its
segments as `"link": {"url": …, "anchor": …}`, and each paragraph's `fields` and `bookmarks`.

### `add_bookmark`

`add_bookmark` names a span of the document, as Word's Insert › Bookmark does: whole paragraphs, from the
start of the first to the end of the last, or one text match in a paragraph. A field or cross-reference
names the span by it, such as an `INCLUDETEXT` field in another document that includes only that part of
this one ([Field and cross-reference runs](#field-and-cross-reference-runs)).

```json
{"type": "add_bookmark", "paragraphs": {"from": {"index": 2}, "through": {"index": 331}}, "name": "DPABody"}
{"type": "add_bookmark", "paragraphs": {"index": 14}, "text": {"match": "Effective Date"}, "name": "EffectiveDate"}
```

`name` is a letter, then letters, digits and underscores, 40 at most. Refused: a name starting with an
underscore, which Word keeps for the bookmarks it hides (`_Ref`, `_Toc`), and a name the document already
uses, whatever its case, since Word keeps one bookmark of each name. A bookmark is not a revision: it is
added untracked, as Word adds one with changes tracked, and the listing shows it among the `bookmarks` of the
paragraph where it starts.

### Cross-references after edits

After the last edit, the server recomputes every `REF` field in the body, headers, footers and notes, so a
cross-reference shows the number or text its target has in the edited document: a paragraph that was
renumbered by an insert, a heading whose text was replaced, a clause moved elsewhere. A field whose result
changes is updated as a tracked change (the old result deleted, the new one inserted, with the request's
author and date), so rejecting restores the old result.

- `REF` fields only are recomputed. A `PAGEREF` or other page-dependent field is only marked for Word to
  update on open (`w:dirty`), since the page depends on layout.
- Plain-text references, such as "see Section 2.1" typed as text, are not fields and are left untouched.
- A `REF` or `PAGEREF` whose target paragraph an edit deletes is refused, and so is the request; a field
  inside another field is left alone. The backend checks `delete_paragraphs` at request time from the
  listing (each paragraph's `fields` and `bookmarks`) and names the referencing paragraphs; edit or remove
  those references in the same request (a `replace` of the reference text removes the field, or delete the
  referencing paragraph), or keep the target. A `join_paragraphs` keeps the joined paragraphs' text and
  bookmarks, so it never removes a target. A reference in a footnote or endnote the request removes (a
  `delete_note`, or a `delete_paragraphs` of the paragraph holding the note's reference) goes with the note.
  Where a `replace` or `delete` in a paragraph removes a field, the backend leaves that paragraph to the
  edit as it is applied, against the edited document. A deletion of the target that the listing cannot show, such as one
  inside a table deleted as a whole, fails the edit with `INVALID_EDIT`.

### `refresh_fields`

`refresh_fields` marks every field in the body, headers, footers and notes for update (`w:dirty`) and sets
`w:updateFields`, so Word asks the reader to update fields when the document opens.

```json
{"type": "refresh_fields"}
```

Both changes are applied untracked, to both outputs. A document holding a field that fetches or runs
content (as listed for moves, `RD` included) is refused, since refreshing would run it on open.

### `insert_table_of_contents`

`insert_table_of_contents` places a `TOC` field as a new paragraph at a destination in the body outside
tables. `from_level` and `to_level` (1 to 9) are the heading levels; `hyperlinks` (`\h`) and
`page_numbers` default to `true`.

```json
{"type": "insert_table_of_contents", "destination": {"paragraph": {"index": 2}, "position": "after"},
 "from_level": 1, "to_level": 3}
```

The defaults with levels 1 to 3 give Word's own `TOC \o "1-3" \h \z \u`; `"page_numbers": false` adds
`\n`. Version Story does not lay out pages, so the field shows a placeholder and is marked for update, with
`w:updateFields` set, both untracked: Word writes the entries when the document opens. The paragraph is
one tracked insertion. A heading above it is an `insert_paragraphs` at the same destination, earlier in
the request.

## Footnotes and endnotes

### Note runs

A `footnote` or `endnote` run in an `insert`'s content adds a note whose reference goes at that point.
Its text is `content`, or `new_paragraphs` for several paragraphs; it holds text, tabs, line breaks,
fields, cross-references and links, not page or column breaks or other notes. `ref` names the note, and
`{"part": {"ref": …}, "index": n}` its paragraphs.

```json
{"type": "insert", "paragraphs": {"index": 56}, "at": {"after": {"match": "409A."}},
 "content": [{"type": "footnote", "ref": "fn1",
              "content": [{"text": "As defined in "}, {"text": "Section 1", "format": {"italic": true}}, {"text": "."}]}]}
{"type": "insert", "paragraphs": {"index": 30}, "at": {"before": {"match": "Schedule"}},
 "content": [{"type": "endnote", "content": "See the Schedule."}]}
```

The reference and the note's text are tracked insertions, written as Word writes a note inserted with
Track Changes on, and share one revision, so deciding it decides the note whole. The notes part, with
Word's separator notes, and the note styles are added untracked when the document lacks them.

### `delete_note`

`delete_note` deletes a note and its reference mark.

```json
{"type": "delete_note", "note": {"kind": "footnote", "index": 1}}
```

The reference run and the note's text become tracked deletions; accepting removes the note. Text edits
reach a note's paragraphs through its `part` locator. The listing prints each note's `part`, `label`,
`paragraph` (where its reference is), `text_before` (the 200 characters before the reference) and
`blocks`, its paragraphs.

## Comments

### `add_comment`

`add_comment` anchors a comment to the one match of `text` in a paragraph, to the whole paragraph, to
a range of paragraphs, or, with `revision` instead of `paragraphs`, to a tracked revision the listing's
`revisions` gives by `index`. `content` is a string or text, tab and line-break runs; `author`
overrides the request's for this comment; `ref` names the thread.

```json
{"type": "add_comment", "paragraphs": {"index": 7}, "text": {"match": "30 days"}, "content": "Should this be 45?", "ref": "c1"}
{"type": "add_comment", "paragraphs": {"from": {"index": 25}, "through": {"index": 27}}, "content": "Double check this section."}
{"type": "add_comment", "revision": {"index": 3}, "content": "Changed at the client's request."}
```

A comment on a revision covers what the revision changes: the text it inserts, deletes or moves or whose
formatting it changes, through the end of a paragraph whose mark it changes; the whole paragraph whose
properties or numbering it changes; and the whole table, row or cell it changes. A move's comment is at
its destination. A revision of paragraph marks alone (a split or a join) covers those paragraphs. One
that runs from the text around a text box into the box covers that text, the box included. Its
reference is outside the revision, so accepting or rejecting the revision keeps the comment. A revision
in a header, footer or note, or one that changes only the last section's page setup, is refused. To
annotate a comparison's redline, read its `revisions` from the json listing of the comparison's `file.file_id`
(`GET /v1/files/{file_id}?format=json`), send one
`add_comment` per revision, and download the result's `files.comparison`, which keeps the tracked
changes beside the comments.

A comment is not a tracked change: it appears in the redline's comment pane and is kept in the clean
copy, unless its reference goes with deleted text. A comment's range follows its text, so a later
`replace` inside it leaves the deleted and the new text inside the comment. Comments go on body and
table-cell paragraphs; Word doesn't support them in headers or footers, and they aren't added to notes.

### `reply_comment` and `resolve_comment`

`reply_comment` answers a thread, and `resolve_comment` marks it resolved. `comment` is a thread's
`index` from the listing's `comments`, or the ref of a thread this request adds.

```json
[{"type": "reply_comment", "comment": {"ref": "c1"}, "content": "Agreed; changed to 45.", "author": "Lawyer A"},
 {"type": "resolve_comment", "comment": {"index": 2}}]
```

Replies and resolutions are annotations, applied untracked. Replies to one thread appear in request
order. The listing gives each thread's `index`, `author`, `date`, `text`, `paragraph`, `anchor_text`,
`resolved` and `replies`.

## Tracked revisions

### `accept_revision` and `reject_revision`

`accept_revision` and `reject_revision` decide the document's existing tracked changes. `revision` is one
entry of the listing's `revisions` (`{"index": n}`), `"all"`, or a filter by `author` and/or `type`
(`insertion`, `deletion`, `move`, or `property`: every formatting, paragraph, numbering, table and section
property change).

```json
[{"type": "reject_revision", "revision": "all"},
 {"type": "accept_revision", "revision": {"author": "Jane Doe"}},
 {"type": "accept_revision", "revision": {"index": 3}}]
```

A request holding a decision holds only decisions; to decide and then edit, send two requests. Decisions
apply in request order and the later one wins for a revision, so the example rejects everything but
Jane Doe's changes and revision 3. Accepting keeps what an insertion or a move's destination adds, drops
what a deletion or a move's source removes, and keeps a property change's new properties; rejecting does
the opposite. A decision is not itself a tracked change: the redline keeps every revision no decision
covered, under its original author and date. Decisions cover the body, headers, footers and notes.
Rejecting all of an edit set's revisions gives back the document it was applied to. An empty filter is
refused, and so is a filter that selects no revision, unless the decision sets `skip_if_none: true`: then it
decides nothing. Accepting every insertion and rejecting every deletion of a document that may lack either,
keeping its moves and formatting changes tracked in the edit's `redline`:

```json
[{"type": "accept_revision", "revision": {"type": "insertion"}, "skip_if_none": true},
 {"type": "reject_revision", "revision": {"type": "deletion"}, "skip_if_none": true}]
```

`skip_if_none` goes only with a filter. A revision unit is a move (both halves), one author's contiguous insertion or deletion, or one
property change; a replacement is two units. A Word 2003 numbering change (`w:numberingChange`) cannot be
rejected; rejecting it fails the job.

## Document settings

### `set_document_settings`

`set_document_settings` sets how the output behaves in Word. Settings are not document content and Word
cannot track them, so they are applied untracked to both outputs, and deciding revisions doesn't change
them.

```json
{"type": "set_document_settings",
 "settings": {"track_changes": true, "protection": {"edit": "tracked_changes", "password": "s3cret"},
              "remove_personal_information": true}}
```

| Field | Effect in the output |
|---|---|
| `track_changes` | `true` turns on Track Changes (`w:trackRevisions`); `false` turns it off. |
| `protection` | Restrict Editing, enforced (`w:documentProtection`), replacing any protection the document had. `edit` is `tracked_changes` (every change is tracked and tracking can't be turned off), `read_only` or `comments`. With `password`, Word asks for it to stop the protection. `false` removes protection. |
| `remove_personal_information` | `true` sets Word's "remove personal information from file properties on save" and clears the output's creator and last-modified-by names; `false` turns it off. |

Fields left out are left as the document has them; at least one is required. `password` is 1 to 15
printable ASCII characters, hashed when the request arrives as Word hashes it, and only the hash is
stored and written; Restrict Editing guards against accidental changes and is not encryption.
A request holds at most one `set_document_settings` edit. `track_changes: false` with `tracked_changes` protection is refused. The even-and-odd headers setting isn't
included. The listing shows the document's own `settings`.

---

---

<!-- https://www.versionstory.com/developers/reference/formats-and-limits -->

# Formats & limits

> Supported file types, output formats, size caps, link expiry, retention, trials, and quotas.

> Integrating Version Story into a codebase as a coding agent? Follow https://www.versionstory.com/developers/quickstart.md: it signs the engineer in, gets an API key, and puts the PDF redline in their app first.

## Source documents

| Format | Compare | Merge | Edit |
| --- | --- | --- | --- |
| `.docx` | Yes | Yes | Yes |
| `.doc` | Yes | Yes | Yes, converted to `.docx` |
| `.pdf` | Yes | Yes | Yes, converted to `.docx` |
| `.pptx` | Two presentations, compared with each other | — | — |
| `.xlsx` | Two workbooks, compared with each other | — | — |

Documents that are not already `.docx` are converted before comparison, so `.doc` and `.pdf` sources
spend noticeably longer in `processing` than `.docx` ones. A presentation or workbook can only be
compared with another of the same kind.

### Redline PDFs

A PDF that is a redline, with insertions underlined in one color and deletions struck through
in another (blue and red, as Litera and Version Story print them), is converted with its
markup turned into tracked changes. Each insertion becomes a tracked insertion and each
deletion a tracked deletion, in body text, tables (including whole inserted or deleted rows),
headers and footers, and footnotes. Moved text, which Litera and Version Story mark in green,
becomes a deletion where it was and an insertion where it went. The redline's colors,
underlines and strikethroughs are removed, so accepting every change gives the revised text
and rejecting every change gives the original. The changes are attributed to `Redline`.

This is on by default. Send `track_redline_changes=false` with the upload to keep the markup
as plain formatting instead. The field is on `POST /v1/files`, `POST /v1/compare` and
`POST /v1/merge`. It applies to the documents that request uploads: a file already in Version Story keeps the choice its
upload made. A PDF that is not a redline, and every other kind of file, is unaffected.

In a comparison or a merge, a redline PDF therefore reads as its revised text, as a Word file
with pending tracked changes does (see
[Keeping tracked changes](/developers/rest/compare#keeping-tracked-changes)).
With `track_redline_changes=false`, its struck-through text is read as part of the document.

## Output formats

Pass the renderings you want as the `format` query parameter. It is repeatable and also accepts a
comma-separated list, so `?format=pdf&format=docx` and `?format=pdf,docx` both ask for two. A request
that names no format gets the Word file, so name `pdf` to show a redline.

| `format` | Compare | Merge | Use it for |
| --- | --- | --- | --- |
| `pdf` | PDF rendering of the redline | PDF rendering of the merged document | Showing a person the document. Start here |
| `html` | The interactive redline, to [embed](/developers/rest/embed) | — | An interactive view beside the PDF |
| `docx` | Word document with tracked changes (the default) | The merged Word document (the default) | A lawyer to review in Word and send on |
| `pdf_changed_pages_only` | PDF of only the changed pages | — | A quick review of a long agreement |
| `md` | The redline as Markdown | The merged document as Markdown | A model to read |
| `json` | The redline as structured data | The merged document as structured data | Code that acts on the changes. Not for display |
| `pptx` | Presentation redline (the default for two `.pptx` sources, whose only other format is `pdf`) | — | |
| `xlsx` | Workbook redline (the only format for two `.xlsx` sources) | — | |

Show people the PDF, embed the HTML where the view must be interactive, give a model the `md`, and
give your code the `json`. Never draw a redline from the `json`: it describes the document's structure
and changes, not its layout in pages. [Why the PDF](/developers/rest/compare#why-the-pdf) says more.

Renderings do not finish together: the PDF and the Markdown trail the Word document. A response
returns every requested format that is ready and names the rest in `pending_formats`, and reports
`status: "ready"` only once all of them exist. Asking for one format gets you an answer sooner than
asking for several.

## Size and rate limits

| Limit | Value |
| --- | --- |
| Maximum file size | 100 MB per file |
| Revisions per merge | 2 to 10 |
| Edits per request | 200, and 200 KB of JSON |
| Pictures per edit | 20, at most 5 MB each and 25 MB in all |
| Suggested poll interval | 1 second, via `Retry-After` |

A request body larger than the biggest legitimate request (a ten-revision merge of 100 MB files) is
refused with `413` before it is read. Files larger than 100 MB need the in-app upload flow.

## Expiry

Every download link is valid for **one hour** from issue. This is rarely a real constraint: the status
endpoints mint fresh links on every call, so a finished result stays reachable; only the link itself
ages out. Store ids or files, never links.

## Retention

Results are kept, and stay downloadable, unless the organization turns on **zero-day
retention** on the web app's Developer page. With it on, everything the API makes is
permanently deleted an hour after it finishes, or as soon as you acknowledge the download,
whichever is first. [Data handling](/developers/reference/data-handling#retention) lists each
kind of work and its acknowledge endpoint.

## API trials

A new organization's API access starts as a trial, and some organizations are set up with
one deliberately. A trial has three limits, shown on the Developer page's Overview tab:

- **A comparison allowance.** Each comparison and each edit counts once; a merge counts once
  per revision. Uploads, status requests and downloads are free. When it is used up, new work is
  refused with `403 TRIAL_COMPARISON_LIMIT_REACHED`.
- **A per-minute rate.** Requests past it are refused with `429 TRIAL_RATE_LIMITED` and a
  `Retry-After` header saying how many seconds to wait. A merge with more revisions than
  the per-minute rate is refused outright with `400 TRIAL_BATCH_TOO_LARGE`.
- **Optionally, no Word downloads.** A trial may exclude `docx`: asking for it is refused
  with `403 TRIAL_WORD_DOWNLOADS_EXCLUDED`, and a request that names no format gets a
  `pdf` for a comparison or an edit and an `md` for a merge instead.

Allowance and rate are checked before anything is created, so a refused request costs
nothing. To move past a trial, [talk to us](/resources/demo).

## Quotas

Organizations on a metered plan have a monthly document-upload allowance covering free
seats. When it is exhausted, the API returns `402` with code `USAGE_LIMIT_REACHED`. The check
runs before anything is created, so a rejected request costs nothing.

This is a billing state, not a transient failure; retrying will not clear it. Paid seats
are not subject to the upload allowance.

---

<!-- https://www.versionstory.com/developers/reference/data-handling -->

# Data handling

> What touches a document between upload and redline, and what is kept afterwards: the deterministic engine, training, human access, zero-day retention, and where it runs.

> Integrating Version Story into a codebase as a coding agent? Follow https://www.versionstory.com/developers/quickstart.md: it signs the engineer in, gets an API key, and puts the PDF redline in their app first.

Law firms send privileged documents through this API. This page says exactly what happens to
a document from the moment it is uploaded, what does not happen to it, and what is left
afterwards. The compliance side (SOC 2 Type 2, encryption, penetration testing) is on the
[Security page](/security).

## What happens to a comparison

1. `POST /v1/compare` stores the two files in S3, encrypted at rest.
2. The comparison engine, a rules-based program written in Rust, computes the redline. It is
   deterministic: the same two versions always produce the same redline.
3. The renderings you asked for (PDF, embeddable HTML, Word with tracked changes, changed pages
   only, Markdown, JSON) are written to S3.
4. `GET /v1/compare/{comparison_id}` mints signed download URLs, each valid for one hour.

No language model is involved in any of these steps, and nothing about the documents' contents
is logged for AI. Version Story is not an AI application. It is an application that AIs and
people can use.

## AI is off by default

Nothing goes through a model unless your organization asks us to turn on an AI feature. Two
exist: AI conversion of scanned PDFs, and Analytics reports. Neither is on for a new
organization, and neither is used by compare, merge, or edit. With them off, which
is the default, no document you send ever reaches a model.

## Training and human access

- Nothing you upload is used to train or improve any model, ours or a vendor's.
- No person reads your documents to produce a redline. There is no human review step, no
  contractors, and no crowd workers anywhere in the process.
- Access by Version Story staff is role-based and recorded in audit logs.

## Retention

By default, what the API makes is kept so you can come back for fresh download URLs.
Organizations that would rather keep nothing turn on **zero-day retention** on the web app's
**Developer** page.

With it on, everything the API makes from then on is deleted permanently once you have the
result. Only a record that the work existed remains. Each kind of work has its own deadline and
its own acknowledge call, and deletion happens at whichever comes first:

| Work | Deleted one hour after | Or when you acknowledge with | What is deleted |
| --- | --- | --- | --- |
| A comparison | It finishes | [`POST /v1/compare/{comparison_id}/acknowledge`](/developers/rest/compare#zero-day-retention) | Both documents, the redline, and every rendering |
| A merge | The merged document finishes | [`POST /v1/merge/{merge_id}/acknowledge`](/developers/rest/merge#post-v1mergemergeidacknowledge) | The base, the revisions, their redlines, and the merged document |
| An edit | The edit finishes | [`POST /v1/edit/{edit_id}/acknowledge`](/developers/rest/edit#post-v1editeditidacknowledge) | The edited file and its redline |
| An uploaded file | It is created | [`POST /v1/files/{file_id}/acknowledge`](/developers/rest/files#post-v1filesfileidacknowledge) | The file |
| A created document | It is created | [`POST /v1/create/{creation_id}/acknowledge`](/developers/rest/create#post-v1createcreationidacknowledge) | The document |

Each download's `expires_at` is the deadline, so it tells you exactly how long you have. Call
the acknowledge endpoint as soon as you have saved what you need. Afterwards, reading the work
answers `410 CONTENT_DELETED`.

The setting is stamped onto the work when it is created, so turning it on or off never changes
work that already exists. Anything made from a file, such as an edit or a redline, is kept on
that file's terms.

In the web app, documents stay in your workspace until you delete them, and organization admins
can set automatic deletion for inactive projects.

Deleted documents remain only in encrypted backups, which expire on a fixed schedule; content under zero-day retention is never backed up.

## Where it runs

- Comparisons run on AWS inside a private VPC.
- Files are held in S3 only while they move between services, encrypted in transit (TLS) and
  at rest (AES-256).
- The API runs in AWS's US region, at `api.versionstory.com`.

## Agreements

Our [terms of use](/legal/terms) and [privacy policy](/legal/privacy) cover confidentiality.
Firms that need their own agreement for privileged work can have one; write to
[security@versionstory.com](mailto:security@versionstory.com).

## Related

- [Security](/security)
- [Formats & limits](/developers/reference/formats-and-limits)
- [Compare](/developers/rest/compare), including the zero-day retention endpoints

---

<!-- https://www.versionstory.com/developers/reference/redline-format -->

# Redline format

> The Markdown a model reads: a document's listing, with every tracked change marked inline and each revision's author and date listed once.

> Integrating Version Story into a codebase as a coding agent? Follow https://www.versionstory.com/developers/quickstart.md: it signs the engineer in, gets an API key, and puts the PDF redline in their app first.

`format=md` gives a model the changes as text, so it can read them directly instead of unzipping a
`.docx` or re-deriving the diff from the source documents. It is working material for analysis, not a
deliverable: show a person the [PDF redline](/developers/rest/compare#why-the-pdf) (or the
[HTML redline](/developers/rest/embed) where the view must be interactive), hand them the Word redline
to edit, and give the model this.

Every `md` the API serves is the same listing: a comparison's redline (`file` in
`GET /v1/compare/{comparison_id}`, and the deprecated top-level `downloads`), a
[merge's](/developers/rest/merge) document, any [file](/developers/rest/files), an edit's two files, a
created document and a [Document's](/developers/rest/documents) versions. It is the document with each
paragraph's address and every change marked inline, the same text edits are written from. The
[JSON](/developers/reference/json-format) is the same listing as structured data.

## A redline as a listing

The listing opens with a header, an HTML comment explaining each tag it prints, then the document,
one paragraph per line. Each line starts with the paragraph's `<p index="n" id="…"/>` tag and holds
its text with every tracked change marked inline:

```html
<p index="1" id="f4378834652a478eb5c67af7fa1b9eea" alignment="justify"/><b><smallcaps>This Non-Disclosure Agreement </smallcaps></b>(this “<b><i>Agreement</i></b>”) is entered into between <del revision="0"><b><smallcaps>[COMPANY NAME]</smallcaps></b></del><ins revision="1"><b><smallcaps>Acme, Inc.</smallcaps></b></ins>, a Delaware corporation …
<p index="13" id="0b6e2f4a9c8d4e1f8a7b6c5d4e3f2a1b" label="7." list_level="1" style="ListNumber"/>Confidential Information is and shall remain the sole <ins revision="6">and exclusive </ins>property of the Company. …
<p index="15" id="7c1d0e9f8a2b4c3d9e8f7a6b5c4d3e2f" label="9." list_level="1"/>Confidential Information will not be reproduced in any form except as <del revision="12">required</del><ins revision="13">is strictly necessary</ins> to accomplish the <del revision="14">intent</del><ins revision="15">purpose</ins> of this Agreement. …
```

After the body come the headers and footers, notes and comment threads, then one line per revision:

```html
<revision index="0" type="deletion" author="Dana Reyes" date="2026-10-22T03:37:30Z"/>
<revision index="1" type="insertion" author="Dana Reyes" date="2026-10-22T03:37:30Z"/>
```

| Element | Meaning |
| --- | --- |
| `<p index="n" id="…"/>` | Starts a paragraph: its `index` and `id`, which an edit names. A numbered paragraph adds its `label` and `list_level`; `style` is printed where it changes |
| `<ins revision="n">` | Text the later version inserted |
| `<del revision="n">` | Text the later version deleted |
| `<moved-from revision="n">` / `<moved-to revision="n">` | Where moved text was, and where it is now. Both halves name the same revision |
| `<revision index="n" type="…" author="…" date="…"/>` | One line per revision, after the body. A revision no element shows, such as a formatting or paragraph mark change, also prints what it changes (`on`), where (`part`, `paragraph`, or its `table`, `row` or `cell`), and the start of its `text` |

`revision` on a change element is the index of its `<revision/>` line, the `{"index": n}` that an
[`add_comment`](/developers/rest/edit-operations#addcomment) or a revision decision names. Who made a
change and when is on that line, once, rather than on every element. Changes nest when revisions
changed the same text, so both attributions survive.

Tables print as `<table index id rows columns>`, `<row index id/>` and `<cell index id column/>` lines,
with `span` on a merged cell; equations as `<equation>…</equation>`; pictures as
`<picture index="n"/>`. A list paragraph's number is its `label` attribute, never text. The listing's
own header says what every tag it prints means.

## Merged documents

A merge's Markdown is the same listing. Each revision's `author` is its label, the name and the
revision's file name, so the `<revision/>` lines say which draft each change came from. Where two
revisions changed the same words differently, both changes are shown, each naming its revision:
the original words are deleted once, credited to both revisions, and each replacement is inserted
beside it.

```html
<p index="31" id="5e4d3c2b1a0f4e9d8c7b6a5f4e3d2c1b"/>"Accounts Date" means <del revision="3">[•]</del><ins revision="4">April 15, 2027</ins><ins revision="5">December 31, 2026</ins>;
…
<revision index="4" type="insertion" author="Dana Reyes — nda_from_counsel.docx" date="2026-10-22T03:37:30Z"/>
<revision index="5" type="insertion" author="Sam Patel — nda_from_finance.docx" date="2026-10-22T03:41:02Z"/>
```

Surface competing changes like these to a person; do not resolve them automatically. The
[JSON](/developers/reference/json-format#segments) wraps each one in a `conflict` segment, for code
that lists them.

## Reading a long listing

The listing of a long document can be large. `GET /v1/files/{file_id}?format=md` can return only the
paragraphs that hold a phrase, or the listing in parts; see
[Reading the listing](/developers/rest/files#reading-the-listing).

`md` is not offered for presentation or spreadsheet comparisons. The `docx` redline carries the same
set of tracked changes either way.

---

<!-- https://www.versionstory.com/developers/reference/metadata -->

# Metadata types

> Every kind of hidden data a metadata clean finds in Word, Excel, PowerPoint and PDF files, and what the standard and strict presets do to each.

> Integrating Version Story into a codebase as a coding agent? Follow https://www.versionstory.com/developers/quickstart.md: it signs the engineer in, gets an API key, and puts the PDF redline in their app first.

A metadata clean ([`clean_metadata`](/developers/rest/files#clean-metadata) on
`GET /v1/files/{file_id}`) reports every kind of hidden data
it found by the type names below, in `metadata_report.items`, with the action it took. The
names are stable: match on them.

| Action | Meaning |
| --- | --- |
| Removed | Deleted from the file |
| Reset | Replaced with a neutral value: the application's name without its version, a template path dropped |
| Accepted | Tracked changes accepted into the text |
| Anonymized | Names replaced with "Author", the same for every person |
| Converted | A field replaced with the text it showed |
| Unlinked | A link removed, its text kept |
| Applied | A marked redaction carried out, so the text under it is gone |
| Flattened | Drawn into the page, so it looks the same and can no longer be edited or read as data |
| Cleaned | An embedded document cleaned the same way as the document around it |
| Kept | Left in place, and counted in the report |
| Reported | Left in place and counted, because removing it would change what the page shows or how the document works: check it before sending |

The three choices on a request override the preset for their types:
`clean_metadata_revisions` for `tracked_changes`, `clean_metadata_comments` for `comments`,
`annotations` and `page_markup`, and `clean_metadata_authors` for `authors` and
`revision_dates`.

## Properties

What the file says about itself.

| Type | What it is | Formats | `standard` | `strict` |
| --- | --- | --- | --- | --- |
| `author` | Who created the document | Word, Excel, PowerPoint, PDF | Removed | Removed |
| `last_modified_by` | Who saved it last | Word, Excel, PowerPoint | Removed | Removed |
| `creator` | The application that made the PDF's content | PDF | Removed | Removed |
| `producer` | The software that wrote the PDF | PDF | Removed | Removed |
| `revision_number` | How many times it was saved | Word, Excel, PowerPoint | Removed | Removed |
| `created_date` | When it was created | Word, Excel, PowerPoint, PDF | Removed | Removed |
| `modified_date` | When it was last saved | Word, Excel, PowerPoint, PDF | Removed | Removed |
| `printed_date` | When it was last printed | Word, Excel, PowerPoint | Removed | Removed |
| `title` | The title property, which is not the heading on the page | Word, Excel, PowerPoint, PDF | Removed | Removed |
| `subject` | The subject property | Word, Excel, PowerPoint, PDF | Removed | Removed |
| `keywords` | The keywords property | Word, Excel, PowerPoint, PDF | Removed | Removed |
| `description` | The comments property in File › Properties | Word, Excel, PowerPoint | Removed | Removed |
| `category` | The category property | Word, Excel, PowerPoint | Removed | Removed |
| `content_status` | The status property, such as Draft | Word, Excel, PowerPoint | Removed | Removed |
| `identifier` | The identifier property | Word, Excel, PowerPoint | Removed | Removed |
| `language` | The language property | Word, Excel, PowerPoint | Removed | Removed |
| `version` | The version property | Word, Excel, PowerPoint | Removed | Removed |
| `trapped` | A PDF's print trapping flag | PDF | Removed | Removed |
| `company` | The organization the application is licensed to | Word, Excel, PowerPoint | Removed | Removed |
| `manager` | The manager property | Word, Excel, PowerPoint | Removed | Removed |
| `total_editing_time` | How long the document was open for editing | Word, Excel, PowerPoint | Removed | Removed |
| `template_name` | The template it was created from | Word, Excel, PowerPoint | Removed | Removed |
| `hyperlink_base` | The base path relative links resolve against | Word, Excel, PowerPoint | Removed | Removed |
| `application` | The application and version that saved it, reset to the product name alone | Word, Excel, PowerPoint | Reset | Reset |
| `document_statistics` | Page, word, character, line and paragraph counts | Word, Excel, PowerPoint | Removed | Removed |
| `part_titles` | The names of the parts (sheets, slides, headings) listed in the properties | Word, Excel, PowerPoint | Removed | Removed |
| `extended_properties` | Any other property the saving application recorded | Word, Excel, PowerPoint | Removed | Removed |
| `custom_properties` | Custom properties, such as a document management system's matter and client numbers | Word, Excel, PowerPoint, PDF | Removed | Removed |
| `sensitivity_labels` | Microsoft Purview sensitivity labels and the label ids behind them | Word, Excel, PowerPoint | Kept | Removed |
| `version_story_ids` | Version Story's own ids, from a document saved by the Version Story app | Word | Removed | Removed |
| `xmp` | A PDF's XMP metadata stream, which repeats and extends its properties | PDF | Removed | Removed |
| `object_metadata` | XMP metadata attached to pages and images inside a PDF | PDF | Removed | Removed |
| `piece_info` | Private data an application (Illustrator, Acrobat) stored in the PDF | PDF | Removed | Removed |

## Review

The traces of drafting and review.

| Type | What it is | Formats | `standard` | `strict` |
| --- | --- | --- | --- | --- |
| `tracked_changes` | Insertions, deletions, moves and formatting changes not yet accepted | Word | Kept | Accepted |
| `comments` | Comments and their replies | Word, Excel, PowerPoint | Kept | Removed |
| `annotations` | PDF notes, highlights, underlines, strikeouts and other review marks | PDF | Kept | Removed |
| `page_markup` | PDF stamps, ink, text boxes and shapes drawn on the page, such as a signature | PDF | Kept | Flattened |
| `authors` | The names on tracked changes and comments | Word, Excel, PowerPoint | Kept | Anonymized |
| `revision_dates` | When each tracked change and comment was made | Word | Kept | Removed |
| `people` | The accounts behind comment authors: email addresses and directory ids | Word, Excel, PowerPoint | Removed | Removed |
| `revision_ids` | The ids Word gives each editing session, which can tie documents to one machine | Word, PowerPoint | Removed | Removed |
| `document_guid` | A unique id that follows the document through every copy | Word | Removed | Removed |
| `revision_log` | A shared workbook's change history | Excel | Removed | Removed |
| `incremental_updates` | Earlier revisions a PDF keeps when it is saved over itself | PDF | Removed | Removed |
| `speaker_notes` | A presentation's speaker notes | PowerPoint | Kept | Removed |

## Hidden content

Content in the file that the page does not show.

| Type | What it is | Formats | `standard` | `strict` |
| --- | --- | --- | --- | --- |
| `hidden_text` | Text formatted as hidden | Word | Removed | Removed |
| `hidden_objects` | Shapes, pictures and text boxes marked hidden | Word, PowerPoint | Removed | Removed |
| `obfuscated_text` | White text, which does not show on a white page | Word | Reported | Removed |
| `small_text` | Text 1.5 points or smaller | Word | Reported | Removed |
| `hidden_sheets` | Hidden and very hidden worksheets | Excel | Reported | Removed |
| `hidden_rows_columns` | Hidden rows and columns | Excel | Reported | Reported |
| `hidden_slides` | Slides marked hidden | PowerPoint | Reported | Removed |
| `off_slide_content` | Shapes placed outside the slide, which never show | PowerPoint | Removed | Removed |
| `hidden_layers` | PDF layers that are switched off | PDF | Reported | Reported |
| `unapplied_redactions` | Redactions marked in a PDF but never applied, so the text is still there | PDF | Applied | Applied |
| `improper_redaction` | Text under a dark box that only covers it on screen | PDF | Reported | Reported |
| `pivot_caches` | The copy of source data a pivot table keeps | Excel | Removed | Removed |
| `scenarios` | What-if scenarios | Excel | Removed | Removed |
| `custom_views` | Saved custom views | Excel | Removed | Removed |
| `unused_layouts` | Slide layouts no slide uses | PowerPoint | Removed | Removed |
| `glossary` | Building blocks and AutoText entries stored in the document | Word | Removed | Removed |

## Links and automation

What connects the document to the firm's systems, or runs on its own.

| Type | What it is | Formats | `standard` | `strict` |
| --- | --- | --- | --- | --- |
| `document_variables` | Variables a template or add-in stored, often from a document management system | Word | Removed | Removed |
| `sensitive_fields` | Fields that show a file name, path, user name or property, converted to their text | Word | Converted | Converted |
| `attached_template` | The path of the template the document is attached to, other than Normal | Word | Reset | Reset |
| `attached_schema` | XML schemas attached to the document | Word | Removed | Removed |
| `mail_merge` | A mail merge's data source and its path | Word | Removed | Removed |
| `smart_tags` | Smart tags | Word | Removed | Removed |
| `custom_xml` | Custom XML parts, often from a document management system or add-in | Word, Excel, PowerPoint | Removed | Removed |
| `custom_xml_bound` | Custom XML that content controls on the page are bound to | Word, Excel, PowerPoint | Kept | Removed |
| `internal_links` | Links to files on a network or local drive | Word, Excel, PowerPoint, PDF | Reported | Unlinked |
| `external_links` | Links to other workbooks | Excel | Reported | Unlinked |
| `data_connections` | Connections to databases and other data sources | Excel | Removed | Removed |
| `defined_names` | Hidden names, broken (#REF!) names, and names that point into other workbooks | Excel | Removed | Removed |
| `formulas` | Formulas, whose results the cleaner leaves as they are | Excel | Reported | Reported |
| `file_path` | The folder the workbook was saved in | Excel | Removed | Removed |
| `file_sharing_user` | The user who protected the workbook for sharing | Excel | Removed | Removed |
| `tags` | Tags add-ins stored on slides and shapes | PowerPoint | Removed | Removed |
| `macros` | VBA macros | Word, Excel, PowerPoint | Reported | Removed |
| `custom_ui` | Custom ribbon and toolbar definitions | Word, Excel, PowerPoint | Removed | Removed |
| `web_extensions` | Office add-ins stored with the document | Word, Excel, PowerPoint | Removed | Removed |
| `javascript` | JavaScript in a PDF | PDF | Removed | Removed |
| `open_action` | An action a PDF runs when it opens | PDF | Removed | Removed |
| `form_fields` | PDF form fields | PDF | Kept | Flattened |

## File contents

Everything else inside the file.

| Type | What it is | Formats | `standard` | `strict` |
| --- | --- | --- | --- | --- |
| `thumbnail` | A preview image of the first page | Word, Excel, PowerPoint | Removed | Removed |
| `page_thumbnails` | Preview images of a PDF's pages | PDF | Removed | Removed |
| `printer_settings` | Printer settings, which can name a printer on the network | Word, Excel, PowerPoint | Removed | Removed |
| `xml_comments` | Comments inside the file's XML, which no reader shows | Word, Excel, PowerPoint | Removed | Removed |
| `tool_markup` | Markup that editing and document management tools add to the XML | Word | Removed | Removed |
| `unused_media` | Pictures and media nothing in the document shows | Word, Excel, PowerPoint | Removed | Removed |
| `embedded_objects` | Documents embedded inside the document, each cleaned the same way | Word, Excel, PowerPoint | Cleaned | Cleaned |
| `attachments` | Files attached to a PDF | PDF | Removed | Removed |
| `image_metadata` | EXIF and other metadata inside pictures: camera, location, dates | Word, Excel, PowerPoint, PDF | Removed | Removed |

## Left in place

What a reader sees or relies on, which a clean never changes.

| Type | What it is | Formats | `standard` | `strict` |
| --- | --- | --- | --- | --- |
| `document_numbers` | Document numbers and file paths shown in a header or footer, which are visible text | Word | Reported | Reported |
| `protection` | Editing restrictions and passwords | Word, Excel, PowerPoint | Kept | Kept |
| `signatures` | Digital signatures | PDF | Kept | Kept |
| `bookmarks` | PDF bookmarks | PDF | Kept | Kept |

## Warnings

`metadata_report.warnings` names what a clean left for a person to decide, each with its type
and a message to pass on to whoever sends the document. Among them:

- **A signed PDF.** Any change to a signed PDF means its signature no longer verifies.
- **Text under a dark box.** The box hides the text on screen, but the text is still in the
  file. Redact it properly before sending.
- **Document numbers.** A header or footer shows a document number or file path. It is
  visible text, so it was left in place.
- **White text over a picture or page colour.** It may be legible, so it was left in place.
- **Links to file paths or internal sites**, left in place under `standard`.
- **Hidden rows and columns**, left in place because removing them would move cells that
  formulas point at.
- **Macros removed**, so the file is now a macro-free document.

## Related

- [Files](/developers/rest/files#clean-metadata): the request and response
- [Data handling](/developers/reference/data-handling): what is kept, and for how long

---

<!-- https://www.versionstory.com/developers/reference/errors -->

# Errors

> Every error code the REST API returns, and what to do about each.

> Integrating Version Story into a codebase as a coding agent? Follow https://www.versionstory.com/developers/quickstart.md: it signs the engineer in, gets an API key, and puts the PDF redline in their app first.

## Error envelope

Every non-2xx response has the same body. `code` is drawn from a closed vocabulary and
is what your code should branch on; `message` is human-readable and may change.

```json
{
  "error": {
    "code": "UNSUPPORTED_FILE_TYPE",
    "message": "original must be a .docx, .doc, or .pdf file",
    "request_id": "01J..."
  }
}
```

Send an `X-Request-Id` header to correlate your logs with ours. If you don't, one is minted
and echoed back in the response.

### Request errors

| Status | Code | Cause | Retry? |
| --- | --- | --- | --- |
| 400 | `INVALID_REQUEST` | Malformed request: a missing, empty, or unnamed file field, a merge with too few or too many revisions, or an unknown reference topic | No, fix the request |
| 400 | `INVALID_AUTHORIZATION_CODE` | `POST /v1/api-keys`: the code is unknown, expired or used, or its verifier or redirect does not match | No, sign in again |
| 400 | `INVALID_EDIT` | An edit was refused: a stale `schema_version`, a selector that matched nothing or too much, or an undefined combination | No, fix the edits |
| 400 | `UNSUPPORTED_FILE_TYPE` | Extension is not `.docx`, `.doc`, `.pdf`, `.pptx` or `.xlsx`, or a `.pptx` or `.xlsx` paired with anything but another of its kind | No |
| 400 | `DOCUMENT_UNREADABLE` | A submitted `.docx` is not a readable Word file: corrupt, or encrypted | No, check the file opens in Word |
| 400 | `INVALID_COMPARISON_ID` | The comparison id is malformed, or is a `mrg_` merge id | No |
| 400 | `INVALID_MERGE_ID` | The merge id is malformed, or is a `cmp_` comparison id | No |
| 400 | `INVALID_EDIT_ID` | The edit id is malformed | No |
| 400 | `INVALID_CREATION_ID` | The creation id is malformed | No |
| 400 | `INVALID_FILE_ID`, `INVALID_DOCUMENT_ID`, `INVALID_VERSION_ID` | A file, Document or version id is malformed | No |
| 400 | `INVALID_PARENT_VERSION` | A new version's `parent` names no version of the Document | No |
| 400 | `INVALID_CURSOR` | A Documents list `cursor` is malformed | No |
| 400 | `ELEMENT_NOT_FOUND` | An element lookup id is no element of the version's body, or more than one element carries it | No |
| 400 | `UNSUPPORTED_FORMAT` | A requested `format` is unknown, or not valid for this operation | No |
| 400 | `TRIAL_BATCH_TOO_LARGE` | A trial organization's merge has more revisions than its per-minute allowance | No, send fewer revisions |
| 401 | `INVALID_API_KEY` | Missing, malformed, unknown, revoked, or expired key | No, check the credential |
| 402 | `USAGE_LIMIT_REACHED` | The organization's monthly upload allowance is exhausted | No, it's a billing state |
| 403 | `API_KEY_ORG_MISMATCH` | The key's service account no longer belongs to the key's organization | No, issue a new key |
| 403 | `TRIAL_COMPARISON_LIMIT_REACHED` | The organization's API trial allowance is used up | No, it's an account state |
| 403 | `TRIAL_WORD_DOWNLOADS_EXCLUDED` | `docx` was requested, and the API trial excludes Word downloads | No, ask for another format |
| 403 | `ZERO_DAY_RETENTION_UNSUPPORTED` | A Documents route, or a Document's version, was used by an organization with zero-day retention | No, it's an account setting |
| 404 | `COMPARISON_NOT_FOUND` | No such comparison, or it belongs to another organization | No |
| 404 | `MERGE_NOT_FOUND` | No such merge, or it belongs to another organization | No |
| 404 | `EDIT_NOT_FOUND` | No such edit, or it belongs to another organization | No |
| 404 | `FILE_NOT_FOUND` | No such file, or it belongs to another organization | No |
| 404 | `CREATION_NOT_FOUND` | No such creation, or it belongs to another organization | No |
| 404 | `DOCUMENT_NOT_FOUND` | No such Document, or it belongs to another organization | No |
| 404 | `VERSION_NOT_FOUND` | No such version in the Document | No |
| 409 | `CONFLICT` | An acknowledgment of work that is still processing, or that has no zero-day retention; an edit of a document still being prepared; or a file that is already a Document's version | Once it has finished processing; an edit after `Retry-After` |
| 409 | `ELEMENT_MAP_PENDING` | An element lookup needs a redline or listing that is still being made | Yes, after `Retry-After` |
| 409 | `ELEMENT_MAP_UNAVAILABLE` | An element lookup needs a map that does not exist and will not | No |
| 410 | `CONTENT_DELETED` | The work was deleted under zero-day retention | No |
| 413 | `FILE_TOO_LARGE` | A file exceeds the 100 MB limit | No |
| 429 | `TRIAL_RATE_LIMITED` | The API trial's per-minute rate is exceeded | Yes, after `Retry-After` seconds |
| 500 | `INTERNAL_ERROR` | Something went wrong on our side | Yes, with backoff |

The `*_NOT_FOUND` codes intentionally cover both "doesn't exist" and "not yours", so ids
cannot be probed for existence.

`INTERNAL_ERROR` is the only code worth retrying blindly. A response that carries `Retry-After`
tells you when to try again: `TRIAL_RATE_LIMITED`, `ELEMENT_MAP_PENDING`, and `CONFLICT` for work
still being made. Everything else in this table describes a request or account condition that will
produce the same result next time. The
trial codes are explained under [API trials](/developers/reference/formats-and-limits#api-trials).

### Comparison failures

A comparison that cannot be generated is **not** an HTTP error. The request succeeded — it
correctly reported that the work will not finish — so it returns `200` with
`"status": "failed"`:

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

| `error.code` | Meaning | What to do |
| --- | --- | --- |
| `DOCUMENT_PROTECTED` | A source is password-protected or permission-restricted | Remove protection and resubmit |
| `DOCUMENT_UNREADABLE` | A source could not be opened — corrupt or malformed | Check the file opens in Word |
| `DOCUMENT_CONVERSION_FAILED` | A `.doc`, `.pdf`, or email source could not be converted to `.docx` | Convert it yourself and resubmit |
| `SOURCE_UPLOAD_FAILED` | A source did not reach storage intact | Resubmit |
| `PDF_RENDER_FAILED` | The redline generated but the PDF rendering did not | Request `format=docx` instead |
| `COMPARISON_FAILED` | Catch-all — `upstream_code` carries the specific pipeline code | Inspect `upstream_code`; contact support if it recurs |

`failed` is terminal for that comparison. Waiting longer changes nothing; submit a new
comparison to retry.

A comparison whose redline succeeded but one of whose renderings did not (a PDF, say) also
reports `failed`, but with no `error`: what was produced is in `file.downloads`, and the
missing formats are named in `file.failed_formats`. A merge whose records can no longer produce a
document reports `MERGE_UNUSABLE`. Edits and created documents fail the same way, with
`EDIT_FAILED` and `CREATION_FAILED`; an amend whose change collides with a later version
reports `CARRY_CONFLICT`. The live text for every code is also served by
[`GET /v1/reference/errors`](/developers/rest/reference).

`DOCUMENT_UNREADABLE` appears in both tables because a `.docx` is checked for readability
while the request is still open — an unopenable one is refused with `400` rather than
accepted and failed later. A `.doc` or `.pdf` source is opaque until its conversion step, so
the same condition surfaces there as a failed status instead.

## Pipeline failure codes

`upstream_code` comes from the document pipeline and is more specific
than the public codes above. The ones you are most likely to see:

| Code | Meaning |
| --- | --- |
| `ONE_OR_MORE_DOCUMENTS_ARE_PROTECTED` | A source is password-protected |
| `ONE_OR_MORE_DOCUMENTS_ARE_UNABLE_TO_BE_OPENED` | A source could not be opened |
| `ONE_OR_MORE_DOCUMENTS_FAILED_TO_CONVERT_TO_DOCX` | Conversion to `.docx` failed |
| `ONE_OR_MORE_DOCUMENTS_FAILED_TO_UPLOAD_TO_S3` | A source did not reach storage |
| `FAILED_TO_SAVE_REDLINE_AS_PDF` | PDF rendering of the redline failed |
| `FAILED_TO_GENERATE_COMPARISON` | The comparison itself failed |
| `OUT_OF_MEMORY` | The documents exceeded the processing budget |
| `UNHANDLED_ERROR` | Unclassified failure |

`OUT_OF_MEMORY` on very large or deeply nested documents is worth reporting to support —
it is usually a fixable characteristic of the specific file rather than a hard limit.

---

<!-- https://www.versionstory.com/developers/rest/reference -->

# Reference topics

> The API's own reference text, served live: error codes, the redline markup, the document listing, and every edit type.

> Integrating Version Story into a codebase as a coding agent? Follow https://www.versionstory.com/developers/quickstart.md: it signs the engineer in, gets an API key, and puts the PDF redline in their app first.

`GET /v1/reference/{topic}` returns one reference topic as text, the same text the MCP
server's `reference` tool returns. It comes from the deployed API itself, so it is always
current, which makes it the reference to hand an agent that is building or running your
integration.

| | |
| --- | --- |
| Base URL | `https://api.versionstory.com` |
| Auth | `Authorization: Bearer vs_...`. See [Authentication](/developers/authentication) |

```bash
curl -sS https://api.versionstory.com/v1/reference/errors \
  -H "Authorization: Bearer vs_live_..."
```

```json
{ "topic": "errors", "text": "..." }
```

| `topic` | What it covers |
| --- | --- |
| `errors` | What each error code means, and what to do about it |
| `redline` | How to read the changes in a comparison's or a merge's Markdown |
| `listing` | Every field of a document's [listing](/developers/rest/edit#the-listing) (`GET /v1/files/{file_id}?format=md` or `json`), and how a text selector matches |
| `edits` | Every edit type [`POST /v1/edit`](/developers/rest/edit) takes, with an example of each |
| `local_copy` | Working with the user's own copy of a document: recording it, editing it, and replacing it |

The call creates nothing and costs nothing against usage, so it is also a cheap way to check
that a key works: `200` means it does. An unknown topic is `400 INVALID_REQUEST`, and the
message lists the topics.

## The OpenAPI specification

The whole API is also described as OpenAPI at
[`https://api.versionstory.com/openapi.json`](https://api.versionstory.com/openapi.json), served from
the deployed API and readable without a key. Generate a client from it, or hand it to an agent along
with these pages.

## Related

- [Errors](/developers/reference/errors): the same codes, with HTTP statuses
- [Redline format](/developers/reference/redline-format): the Markdown markup in detail

---

<!-- https://www.versionstory.com/developers/reference/json-format -->

# Document JSON

> format=json: a document and its changes as structured data, for code that acts on them. Not for display: show the PDF, or embed the HTML.

> Integrating Version Story into a codebase as a coding agent? Follow https://www.versionstory.com/developers/quickstart.md: it signs the engineer in, gets an API key, and puts the PDF redline in their app first.

> **Showing a redline?** Use the [PDF](/developers/rest/compare#why-the-pdf), or
> [embed the HTML](/developers/rest/embed) for an interactive view. This format is for code that
> acts on the changes.

`format=json` is a document as structured data: its paragraphs, headings and tables in order, its
text with every tracked change marked, and beside the body its headers, footers, notes, comments and
revisions. It is the same listing as the [Markdown](/developers/rest/edit#the-listing), shaped for
code. Every endpoint that carries a document serves it:

- a comparison's redline, `file` in [`GET /v1/compare/{comparison_id}`](/developers/rest/compare);
- a merge's document, `file` in [`GET /v1/merge/{merge_id}`](/developers/rest/merge), with `kind`
  after `schema_version`;
- any [file](/developers/rest/files), an [edit's](/developers/rest/edit) two files, a
  [created](/developers/rest/create) document, and a [Document's](/developers/rest/documents) versions.

Ask for it with `format=json` and download it from `file.downloads[].url`, like any other format.
The OpenAPI specification publishes its shape as `components.schemas.StructuredDocument`.

## What it is for

Use it for what your code decides about the changes: counting insertions and deletions, listing the
paragraphs that changed, routing a document when a clause you care about changed, linking a change
or paragraph to a record in your own app (and to the embedded HTML's
[`vs:scrollTo`](/developers/rest/embed#linking-to-a-place-in-the-document)), and reading the locators
an [edit](/developers/rest/edit) names.

```python
def changes(segments):
    """Every tracked change in a list of segments, nested changes included."""
    for segment in segments:
        if segment.get("type") in ("insertion", "deletion", "moved_from", "moved_to"):
            yield segment
        yield from changes(segment.get("content", []))


def paragraphs(blocks):
    """Every paragraph and heading, table cells' included."""
    for block in blocks:
        if block["type"] == "table":
            for row in block["rows"]:
                for cell in row["cells"]:
                    yield from paragraphs(cell["blocks"])
        else:
            yield block


redline = requests.get(json_url).json()
changed = [p for p in paragraphs(redline["blocks"]) if any(changes(p.get("content", [])))]
print(f"{len(redline.get('revisions', []))} changes in {len(changed)} paragraphs")
for paragraph in changed:
    if paragraph.get("label", "").startswith("7"):
        print("Section 7 changed:", paragraph["id"])
```

It is not for display. It describes the document's structure and changes, not how the document is
laid out in pages, so a viewer drawn from it re-implements Word's layout and still shows less than
the PDF and the HTML redline Version Story already renders.

## The shape

```json
{
  "schema_version": 1,
  "blocks": [
    {
      "type": "paragraph",
      "index": 1,
      "id": "f4378834652a478eb5c67af7fa1b9eea",
      "style_id": "Normal",
      "content": [
        { "text": "This Non-Disclosure Agreement (this “Agreement”) is entered into between " },
        { "type": "deletion", "revision": { "index": 0 },
          "content": [{ "text": "[COMPANY NAME]", "editable": false, "format": { "bold": true } }] },
        { "type": "insertion", "revision": { "index": 1 },
          "content": [{ "text": "Acme, Inc.", "format": { "bold": true } }] },
        { "text": ", a Delaware corporation …" }
      ]
    }
  ],
  "notes": [
    {
      "part": { "kind": "footnote", "index": 0 },
      "label": "1",
      "paragraph": { "index": 1, "id": "f4378834652a478eb5c67af7fa1b9eea" },
      "text_before": "This Non-Disclosure Agreement (this “Agreement”) is entered into between Acme, Inc.",
      "blocks": [
        {
          "type": "paragraph",
          "index": 0,
          "id": "b6ac5dabcec1413aa880e0a3a2d9f237",
          "style_id": "FootnoteText",
          "content": [
            { "type": "deletion", "revision": { "index": 2 }, "content": [{ "text": "Formerly ", "editable": false }] },
            { "text": "Acme Holdings LLC." }
          ]
        }
      ]
    }
  ],
  "revisions": [
    { "index": 0, "type": "deletion", "author": "Dana Reyes", "date": "2026-10-22T03:37:30Z",
      "on": ["text"], "text": "[COMPANY NAME]",
      "paragraph": { "index": 1, "id": "f4378834652a478eb5c67af7fa1b9eea" } },
    { "index": 1, "type": "insertion", "author": "Dana Reyes", "date": "2026-10-22T03:37:30Z",
      "on": ["text"], "text": "Acme, Inc.",
      "paragraph": { "index": 1, "id": "f4378834652a478eb5c67af7fa1b9eea" } },
    { "index": 2, "type": "deletion", "author": "Dana Reyes", "date": "2026-10-22T03:37:30Z",
      "on": ["text"], "text": "Formerly ",
      "part": { "kind": "footnote", "index": 0 },
      "paragraph": { "part": { "kind": "footnote", "index": 0 }, "index": 0, "id": "b6ac5dabcec1413aa880e0a3a2d9f237" } }
  ]
}
```

Beside `blocks` and `revisions`, the document carries `headers`, `footers`, `notes`, `comments`,
`settings`, `list_styles`, `paragraph_styles` and `final_section`, each present when the document has
any. A merge's document adds `kind` after `schema_version`.

### Locators

Every `index` counts from 0 within its container: paragraphs and tables within their part (the body,
or one header, footer or note), rows within their table, cells within their row, and sections, notes
of a kind, comment threads, pictures and revisions within the document. Paragraphs, tables, rows and
cells also carry an `id`, which they keep when content moves.

Where the listing points at something else, it prints that thing's locator, the object an
[edit](/developers/rest/edit-operations#locators) sends as it is:

| Points at | Locator |
| --- | --- |
| A body paragraph | `{"index": 12, "id": "…"}`, or `{"id": "…"}` for one the listing gives no index |
| A header, footer or note paragraph | `{"part": {...}, "index": 0, "id": "…"}` |
| A header or footer | `{"kind": "header", "type": "default", "section": {"index": 0}}`: its page type and the first section that shows it |
| A note | `{"kind": "footnote", "index": 0}`: its position among the notes of its kind |
| A revision | `{"index": 4}` |

What Word shows is a `label`, never a position: a list paragraph's generated number (`2.4(a)`) and a
note's mark.

### Blocks

`blocks` is the body in document order. Each block's `type` is `paragraph`, `heading` or `table`.

| Field | Meaning |
| --- | --- |
| `index` | A paragraph's position among its part's paragraphs, counted from 0, table cells' included; a table's among its part's tables, an outer table before those nested in it. The `{"index": n}` an edit names |
| `id` | Its own identifier, kept when content moves. The embedded HTML gives the paragraph the same id |
| `style_id` | Its paragraph style |
| `label` | A numbered paragraph's list label as Word shows it (`2.4(a)`). It is not part of the text |
| `heading_level` | A heading's level, from its Heading 1 to 9 style |
| `list_level` | A numbered paragraph's list level, 1 to 9 as Word counts them |
| `content` | Its text, as a list of segments |
| `paragraph_format`, `section`, `fields`, `bookmarks` | Its formatting where it sets its own, the section that ends at it, and the fields and bookmarks in it |

### Tables

A table holds `rows`, each row holds `cells`, and each cell holds its own `blocks`, so a cell's
content is read exactly like the body.

```json
{
  "type": "table", "index": 0, "id": "5b0e91c4…", "style_id": "TableGrid",
  "rows": [
    { "index": 0, "id": "6e127ae6…", "header": true, "cells": [
      { "index": 0, "id": "a91c03d2…", "column": 0, "blocks": [] },
      { "index": 1, "id": "c4d8e1f0…", "column": 1, "span": 2, "blocks": [] }
    ] },
    { "index": 1, "id": "0d7f52b9…", "change": { "type": "insertion", "revision": { "index": 3 } }, "cells": [] }
  ]
}
```

| Field | On | Meaning |
| --- | --- | --- |
| `index`, `id` | Row, cell | A row's position in its table, and a cell's in its row, counted from 0; and its id |
| `header` | Row | `true` for a header row, repeated on each page |
| `column` | Cell | The grid column the cell starts at, counted from 0. A column locator names it, never the cell's `index` |
| `span` | Cell | The grid columns a merged cell covers, present when more than one. The next cell's `column` is this cell's plus its `span` |
| `change` | Row, cell | A tracked insertion or deletion of the whole row or cell: `{"type", "revision": {"index"}}` |
| `table_format`, `cell_format` | Table, cell | The borders it sets itself |

### Segments

A segment without a `type` is unchanged text, with `text` and, where they apply, `format` and `link`.
A segment with one is one of:

| `type` | Meaning |
| --- | --- |
| `insertion` | Text the later version added |
| `deletion` | Text the later version removed, marked `"editable": false` |
| `moved_from` / `moved_to` | The two halves of one relocation, both naming the same revision |
| `conflict` | In a merge, competing changes from different revisions over the same text, with the conflict's `index`. Surface these to a person; don't resolve them automatically |
| `equation` | A Word equation, as [below](#equations) |
| `tab`, `break` | A tab, or a line, page or column break (`kind`) |
| `picture`, `object` | A picture, with its `index`, size and `alt_text`, or a chart, shape, text box or embedded object |

A change is `{"type", "revision": {"index": n}}` with its `text` when it holds plain text, or its
`content` when its segments carry more: formatting, a link, a break, another change, or a deletion's
`"editable": false`. Its author and date are on its entry in [`revisions`](#revisions), once. Changes nest: a revision that edits inside another revision's text sits in that change's
`content`, so both attributions survive.

In a merge's document, competing changes are wrapped in `{"type": "conflict", "index": n, "content": [...]}`.
Where two revisions replaced the same words, the merge keeps one deletion of them, and that deletion
also lists, as `revisions`, the revision of every change in its conflict:

```json
{ "type": "conflict", "index": 0, "content": [
  { "type": "deletion", "revision": { "index": 3 }, "revisions": [{ "index": 3 }, { "index": 4 }, { "index": 5 }],
    "content": [{ "text": "[•]", "editable": false }] },
  { "type": "insertion", "revision": { "index": 4 }, "text": "April 15, 2027" },
  { "type": "insertion", "revision": { "index": 5 }, "text": "December 31, 2026" }
] }
```

### Equations

A Word equation is one segment, `{"type": "equation", "content": [...]}`. Its content is the
equation in a linear form, as text segments marked `"editable": false`: `(T-D)/T` is a fraction,
`F_m` a subscript, `x^2` a superscript, `∑_(i=1)^n (q_i p_i)` a sum with its limits. A tracked change
inside the equation is a change segment naming its revision.

```json
{ "type": "equation", "content": [
  { "text": "(T-D)/", "editable": false },
  { "type": "deletion", "revision": { "index": 6 }, "content": [{ "text": "T", "editable": false }] },
  { "type": "insertion", "revision": { "index": 7 }, "content": [{ "text": "N", "editable": false }] }
] }
```

No text selector matches an equation's text, so no text edit changes it. The Markdown writes it as
`<equation>…</equation>`.

### Revisions

`revisions` lists every tracked change once per accept or reject decision, in document order: the
body first, then headers and footers section by section, footnotes and endnotes. A replacement is two
entries, a deletion and an insertion. Counting `revisions` is counting changes the way a reviewer
does.

| Field | Meaning |
| --- | --- |
| `index` | Its position, counted from 0. Every change that is part of it names it as `{"index": n}`, and `add_comment`, `accept_revision` and `reject_revision` take the same locator |
| `type` | `insertion`, `deletion`, `move` (both halves of a move) or `property` (a formatting, paragraph, numbering, table or section property change) |
| `author`, `date` | Who made it and when. In a merge's document, `author` is the revision's label, such as `Dana Reyes — nda_from_counsel.docx` |
| `on` | What it changes: `text`, `paragraph_mark`, `paragraph`, `numbering`, `section`, `table`, `table_row` or `table_cell`. `paragraph_mark` is a change to a paragraph's mark: one inserted or deleted, as a split, a join or a whole paragraph added or removed makes, or its formatting |
| `text` | The first 200 characters of the text inserted, deleted, moved or formatted, a paragraph mark as a newline; for another property change, the text of what it changes |
| `part` | Its part, for a revision in a header, footer or note |
| `paragraph` | The locator of the paragraph it starts in |
| `table`, `row`, `cell` | The table, row or cell it changes, by `{"id"}`, when it starts in no paragraph |
| `section` | The section a section property change changes, `{"index": n}` |

`comments` lists the comment threads, each with its `index`, `author`, `date`, `text`, the
`paragraph` its anchor starts in, `anchor_text`, `resolved` and `replies`.

## The older comparison JSON

A comparison's response also carries a `json` in its deprecated top-level `downloads`: the
comparison's older rendering, kept for the integrations built on it. New code reads the `json` in
`file`, above.

It has `schema_version`, `kind` and `blocks`, and nothing else. A list paragraph's label is `number`,
with its `list_id`. Its paragraphs have their text, formatting, tracked changes and `level`, but no
`index`, `heading_level`, `list_level`, bookmarks, paragraph formatting, links, fields, pictures,
objects or equations, and its tables and cells have no position. Each change segment carries its
`id` (Word's own revision id), `author`, `revision` (the document version that introduced it) and
`date`, with `text`, or `content` when changes nest. A whole table row that was inserted or deleted
carries a `change` on the row itself. To reconstruct either version from it:

- **The base document:** everything except `insertion` segments.
- **The revised document:** everything except `deletion` segments, and table rows whose `change` is a
  deletion.

Spreadsheet and presentation comparisons have no JSON.
