# Embed redlines

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

One endpoint returns a comparison's redline as a single, self-contained HTML file: the document
with its tracked changes, the script that lays it out in pages and drives navigation, its
styles and its images, all inlined. You put that file in a sandboxed `iframe` on your own page.
It renders the redline with the Version Story app's Revisions panel beside the pages: change
and comment cards, Filters, and Prev and Next, in a light or a dark theme. 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 | Comparisons created with [`POST /v1/compare`](/developers/rest/compare) of `.docx`, `.doc` or `.pdf` files |

## How it fits together

```text
Your server ──── GET /v1/compare/{id}/html (API key) ────▶ Version Story
     │                                                      returns one HTML file
     ▼
Your page  ── <iframe sandbox="allow-scripts" srcdoc={html}> ── the redline document
     ▲                                                              │
     └──────────────────── postMessage (vs:* messages) ─────────────┘
```

1. Create a comparison with [`POST /v1/compare`](/developers/rest/compare) and poll
   `GET /v1/compare/{comparison_id}` until its `status` is `ready`.
2. **On your server**, call `GET /v1/compare/{comparison_id}/html` with your API key and pass
   `embed_origin`: the origin of the page that will show the redline. The API key never goes to
   the browser.
3. Hand the HTML to your page (as JSON, for example) and set it as the `srcdoc` of an `iframe`
   with `sandbox="allow-scripts"`.
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).

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

Returns the comparison's redline as one self-contained HTML document.

```bash
curl -sS "https://api.versionstory.com/v1/compare/cmp_MTp2cy0xOnZtLTE/html?embed_origin=https://app.example.com" \
  -H "Authorization: Bearer vs_live_..." \
  -o redline.html
```

### Query parameters

| Parameter | Default | Meaning |
| --- | --- | --- |
| `embed_origin` | none | The origin of the page that will embed the document, exactly as the browser reports `window.location.origin`: scheme, host and port only, e.g. `https://app.example.com`. The document exchanges messages only with a parent page at this origin. Without it, the document renders but sends and accepts no messages. Must be `https`, except `http://localhost` and `http://127.0.0.1` for development. A default port (`:443`) is dropped, as browsers drop it. |
| `navigator` | `true` | Whether the Revisions panel shows its controls: the Filters (insertions, deletions, comments and, in a merge, conflicts) and Prev and Next. |
| `sidebar_change_cards` | `true` | Whether each change's card (and each conflict's) is shown in the Revisions panel. |
| `inline_change_cards` | `true` | Whether a change that is clicked or navigated to shows its card over the document, just below the change. Applies only while `sidebar_change_cards` is off; with it on, the change's card in the panel is selected instead. |
| `comment_cards` | `true` | Whether the comments' cards are shown in the Revisions panel. The commented text stays highlighted either way. |

Booleans accept `true`/`false` (or `1`/`0`). The four display parameters 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.

The panel's theme is not a parameter: the document opens in the light theme, and your page
switches it with `vs:setOptions` (see [Themes](#themes)).

### Response — 200 OK

The body is the HTML document (`Content-Type: text/html; charset=utf-8`), typically a few hundred
kilobytes to a few megabytes depending on the document and its images.

| Header | Value |
| --- | --- |
| `Content-Security-Policy` | `sandbox allow-scripts`, so the file never runs with Version Story's origin if opened directly, and the document's own policy (see [Content Security Policy](#content-security-policy)) |
| `Cache-Control` | `private, no-store` |

What the file contains:

- The redline's text and tracked changes, laid out in pages as in Word.
- The document's script and stylesheet, and the Revisions panel with its styles and fonts,
  inlined, as Version Story ships them at the time of the request.
- Every image stored with the document, inlined as a data URI. Images the document links from
  other sites are left out.

The file makes no requests to Version Story. The only external requests it makes are to Google
Fonts, for the document's fonts.

### Errors

Errors use the standard envelope (see [Errors](/developers/reference/errors)).

| Status | `code` | When |
| --- | --- | --- |
| 400 | `INVALID_REQUEST` | `embed_origin` is not a bare `https` origin |
| 400 | `INVALID_COMPARISON_ID` | The comparison id is malformed |
| 401 | `INVALID_API_KEY` | The API key is missing, unknown, revoked or expired |
| 403 | `API_KEY_ORG_MISMATCH` | The key's user no longer operates in the key's organization |
| 404 | `COMPARISON_NOT_FOUND` | No such comparison, or it is not yours |
| 409 | `CONFLICT` | The redline is not ready yet: retry after a second. Also returned, with a different `message`, for a presentation or workbook comparison, a comparison not created with `POST /v1/compare`, or a redline whose HTML could not be rendered |
| 410 | `CONTENT_DELETED` | The comparison's content has been deleted |

The HTML rendering can finish a few seconds after the comparison reports `ready`. Treat a `409`
whose message says the redline is not ready like `processing`, and retry after a second.

## Embedding the document

Put the HTML in an `iframe` with `sandbox="allow-scripts"` and set it as `srcdoc`:

```javascript
const frame = document.createElement("iframe");
frame.setAttribute("sandbox", "allow-scripts");
frame.title = "Redline";
frame.srcdoc = html; // the body of GET /v1/compare/{comparison_id}/html
container.replaceChildren(frame);
```

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

- **Use exactly `sandbox="allow-scripts"`.** Without `allow-same-origin`, the document runs with
  an opaque origin and cannot read your page, its cookies or storage, or navigate it. Never add
  `allow-same-origin` to a `srcdoc` frame: the document would then run with your page's origin.
- **Never insert the HTML into your own page's DOM** (for example with `innerHTML`). Keep it in
  the frame.
- **Never serve the HTML as a page of your own site.** Send it to the browser as data (JSON or
  text) and set `srcdoc` from there.
- **Keep the API key on your server.** The browser only ever receives the HTML.

Give the frame a height. The document scrolls inside it.

### Content Security Policy

The document carries its own Content-Security-Policy, in a `<meta>` tag at the start of its head.
It runs only Version Story's scripts, and blocks every network request except those to Google Fonts.

A `srcdoc` frame inherits the Content-Security-Policy of the page that embeds it. If your page
sends one, it must also allow what the document needs:

```text
script-src 'unsafe-inline';
style-src 'unsafe-inline' https://fonts.googleapis.com;
font-src https://fonts.gstatic.com data:;
img-src data:;
```

If you cannot loosen your page's policy, serve the HTML from a separate origin dedicated to
untrusted content, with its own policy, and frame that URL with the same sandbox instead.

### Fetch it when it is viewed

Fetch the HTML each time someone opens the redline rather than storing it. The file carries the
version of Version Story's document script that was current when you fetched it; fetching on view
means fixes and improvements reach your users on their next view. The response is sent with
`Cache-Control: no-store` for the same reason.

## 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 you passed `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" }, "*");
```

This is safe because the frame holds the `srcdoc` you set, and the document accepts messages only
from its parent at your `embed_origin`.

**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, Prev 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 (`kind` `merge_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 Prev and Next stop at, or `-1` when the selected element is not one of them (a paragraph) |
| `total` | How many elements Prev and Next stop at |

Prev and Next stop at tracked changes, comments, conflicts and footnote references, and
skip the kinds that are hidden (see `showInsertions`, `showDeletions`, `showComments` and
`showMergeConflicts` below). 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`.

**`vs:setOptions`**

```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 Prev and Next (the `navigator` parameter) |
| `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 comparison's [JSON rendering](/developers/reference/json-format)
  (`blocks[].id`, from `GET /v1/compare/{comparison_id}?format=json`). The JSON and the HTML give
  each paragraph the same id, so a paragraph you found in the JSON can be shown in the document.

The ids of tracked changes inside the JSON rendering (`content[].id` of an insertion or deletion)
are not the document's ids. 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. Fetch the HTML with `navigator=false`.
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 pass
`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.

## Example

A complete embedding: a Node server (18 or later, no dependencies) that keeps the API key and
fetches the HTML, 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 ORIGIN = "http://localhost:3000"; // this page's origin, as the browser reports it
const headers = { Authorization: `Bearer ${process.env.VERSION_STORY_API_KEY}` };
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// The redline's HTML, retried while its rendering is still finishing.
async function fetchRedlineHtml(comparisonId) {
  const query = new URLSearchParams({ embed_origin: ORIGIN, navigator: "false" });
  const url = `${API}/v1/compare/${encodeURIComponent(comparisonId)}/html?${query}`;
  for (let attempt = 0; attempt < 30; attempt++) {
    const response = await fetch(url, { headers });
    if (response.ok) return { status: 200, body: { html: await response.text() } };
    const body = await response.json();
    if (response.status !== 409 || !/not ready/i.test(body.error.message)) {
      return { status: response.status, body };
    }
    await sleep(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, ORIGIN);
    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") {
      // Sent as JSON, never as a page of this site: the document's scripts must only run in the sandbox.
      const { status, body } = await fetchRedlineHtml(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?comparison_id=${encodeURIComponent(comparisonId)}`)
      .then((response) => response.json())
      .then((body) => {
        if (body.error) throw new Error(`${body.error.code}: ${body.error.message}`);
        frame.srcdoc = body.html;
      })
      .catch((error) => (document.getElementById("position").textContent = error.message));
  </script>
</body>
</html>
```

## Limits

- Only comparisons created with `POST /v1/compare` of `.docx`, `.doc` or `.pdf` files.
  Presentation (`.pptx`) and workbook (`.xlsx`) comparisons have no HTML rendering.
- 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.
