# 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`, `position`, `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": "9f2c4e1a7b3d4c8e9a1f2b3c4d5e6f70", "index": 1, "kind": "change", "changeType": "insert", "text": "here is a bolded change", "author": "Dana Reyes" },
    { "anchorId": "54d40fe821ef4c13870e2418118ceb12", "index": 0, "kind": "comment", "changeType": "comment", "text": "Here is a comment on v1 NDA", "author": "Corporate Counsel" }
  ]
}
```

| Field | Meaning |
| --- | --- |
| `anchorId` | The element's `id`: the same id the redline's [JSON](/developers/reference/json-format) and [Markdown](/developers/reference/redline-format) give it (a change's revision, a comment's thread, a conflict). Pass it to `vs:scrollTo` to go to it, or to [`/v1/edit`](#accepting-or-rejecting-a-change) to decide a change |
| `index` | The element's `index` in the JSON and Markdown: a change's revision index, a comment's thread index, a conflict's index |
| `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. A change made of several pieces, such as an insertion
spanning two paragraphs or both halves of a move, is one item with one `anchorId`. A reply is listed as a
separate comment with its thread's `anchorId` and `index`.

**`vs:highlighted`**

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

| Field | Meaning |
| --- | --- |
| `anchorId` | The `id` of the element now selected. The selection moves from one element to another; it is never cleared |
| `index` | Its `index` in the JSON and Markdown, or `null` when it has none |
| `position` | 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 `position` and `total` for a counter such as "2 of 9":
`${position + 1} of ${total}`. `position` used to be sent as `index`; `index` is now the element's
own index.

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

The JSON, the Markdown and the HTML give every element they identify the same `id` and the same
`index`: paragraphs, tables, rows, cells, sections, revisions, comment threads, notes, pictures,
headers and footers, and conflicts. `vs:scrollTo` accepts the `id` of any of them but a section, which
has no place of its own on the page. So something your code found in the
[JSON](/developers/reference/json-format), such as a revision
(`revisions[].id`) or a paragraph (`blocks[].id`), can be shown in the document, and an `anchorId`
the document sends can be looked up in the JSON.

## Accepting or rejecting a change

To let a reviewer decide a change in the document, take its `anchorId` from `vs:highlighted` (or from
`vs:changes`) and send it as the revision's `id` in an
[`accept_revision` or `reject_revision`](/developers/rest/edit-operations#acceptrevision-and-rejectrevision)
edit of the redline:

```json
{ "type": "accept_revision", "revision": { "id": "9f2c4e1a7b3d4c8e9a1f2b3c4d5e6f70" } }
```

Check the `kind` first: only a `change` is a revision. The edit decides the whole change, both halves of
a move included, and its result is a new redline with its own HTML; embed that to show the outcome.

## 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.position >= 0 ? `${message.position + 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, each with its thread's `anchorId`.

## 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
