Redline API Quickstart
Compare HTML, Markdown, plain text, JSON, DOCX, or searchable PDF. Get an HTML redline, structured JSON changes, or both. DOCX also supports optional Word Track Changes. Document diffs compare text and supported styles. Optional rendered pages show source layout; DOCX fonts and pagination can differ from Word. Images are displayed, but not compared. Scanned PDFs need OCR.
Send two strings to the content comparison endpoint. These examples use anonymous access within quota; authenticated API calls add the Bearer header described below. The response contains the detected changes.
Python
Install the requests package, then run:
import requests
response = requests.post(
"https://api.bookcicle.com/v1/redline/html",
json={"original": "One year.", "revised": "Two years."},
timeout=30,
)
response.raise_for_status()
print(response.json())
curl
curl https://api.bookcicle.com/v1/redline/html \
-H 'Content-Type: application/json' \
-d '{"original":"One year.","revised":"Two years."}'
JavaScript
const response = await fetch("https://api.bookcicle.com/v1/redline/html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ original: "One year.", revised: "Two years." })
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
console.log(await response.json());
Base URL
https://api.bookcicle.com/v1
Authentication
Anonymous callers can receive HTML comparisons within the existing anonymous size, rate, and usage limits. Exports require a signed-in user or a valid API key. Anonymous callers cannot create export jobs or obtain export download URLs. A private comparison or viewer token does not authorize exports.
All authenticated requests require a Bearer token in the Authorization header:
Authorization: Bearer bc_live_0123456789abcdef0123456789abcdef
You can generate an API key in the API Keys Dashboard. Never share or commit your secret key.
Send your Bearer token and JSON content type. Signed upload URLs include their own authorization.
Payload signing: For direct document requests larger than 1 MiB of serialized JSON, clients must also send x-amz-content-sha256 containing the lowercase SHA-256 digest of the complete request body. Browsers and client SDKs supply this automatically.
Content Comparison API (Format-Agnostic)
Compare HTML, Markdown, plain text, or JSON in one synchronous request. Additions use <ins>; deletions use <del>.
POST /v1/redline/html
Content-Type: application/json
{
"original": "<p>The fox jumped over the fence.</p>",
"revised": "<p>The quick fox jumped over the old fence.</p>",
"mode": "auto",
"format": "html",
"output": ["html"]
}
Choose output: ["html"] (default), ["json"], or ["html", "json"]. These select fields in the response object.
Content comparison request
| Field | Type | Required / default | Meaning |
original | string or upload reference | Required | Baseline content, or {"uploadId":"..."} for staged inputs. |
revised | string or upload reference | Required | Revised content, or {"uploadId":"..."} for staged inputs. |
format | string | "auto" | html, markdown / md, txt / plain, json, or auto. Markdown becomes canonical HTML; JSON source is canonicalized and key-sorted. |
mode | string | "auto" | auto, word (inline words), or block (larger text segments). |
output | array of strings | ["html"] | ["html"], ["json"], or ["html", "json"]. Pass "hosted_viewer" in the output array to receive a secure embed token and hosted comparison URL (comparisonId, viewerUrl, embedUrl, and viewerToken; aliases: viewer, embed). These select object fields, not an array response. |
hostedViewerTtl | string or integer | "24h" | Adjustable availability and token validity window for hosted embeds (when "hosted_viewer" is in output). Creator tier supports "24h" (default) or "32h". Creator Pro supports "24h" (default), "32h", "48h", or "72h". Integer hours (24, 32, 48, 72) or seconds (e.g. 86400) are also accepted. |
stream | boolean | false | Set true for provisional blocks and early canonical text. See Progressive streaming. |
Pass "hosted_viewer" in the output array to receive a secure embed token and hosted comparison URL. See Hosted Viewer & Embeds.
JSON changes refer to the output HTML. See Structured JSON Contract, for the output schema and HTML location mapping.
Content comparison response
| Field | Type | Presence | Meaning |
schemaVersion | string | Always | redline.html.v1 for HTML-only; redline.comparison.v1 when JSON is requested. |
html | string | HTML requested | HTML redline fragment for rendering; omitted for JSON-only. |
cssUrl | URL string | Always | Public Redline v1 stylesheet for the current environment. Load once in your rendering document. |
json | object | JSON requested | First page of structured changes; see the JSON tables below. |
changesUrl | URL string | JSON requested | Private pagination endpoint; preserve its token. Relative URLs resolve against the API origin. |
requestedMode / effectiveMode | string | Always | Requested and selected text diff modes. |
format / engine / fallback | string / string / boolean | Always | Effective input format; dom / text engine; whether fallback was used. |
units | integer | Always | Comparison’s metered Redline units. |
usage | object | Always | used, allowance, remaining, and plan for the account. |
comparisonId | string | Stored comparison | Identifier for stored comparison retrieval. |
viewerUrl / embedUrl | URL string | Stored HTML available | Browser review and embed pages, including private access tokens. These are viewer pages, not raw HTML download URLs. |
viewerToken | string | Stored HTML available | Private comparison access token; treat as a credential. |
Best-effort caller replay is an optimization and does not guarantee single admission. Use Idempotency-Key for durable retry protection in text and document comparisons.
Response
{
"schemaVersion": "redline.html.v1",
"cssUrl": "https://dev.redline.bookcicle.com/styles/v1/redline.css",
"html": "<p>The <ins>quick </ins>fox jumped over the <ins>old </ins>fence.</p>",
"requestedMode": "auto",
"effectiveMode": "word",
"format": "html",
"engine": "dom",
"fallback": false,
"units": 1,
"usage": {
"used": 142,
"allowance": 25000,
"remaining": 24858
}
}
Structured JSON Contract
Changes are anchored to the comparison’s HTML representation for every input format, while source, revised, and alignment provide independent original and revised block models with dual-anchored change locations (sourceLocation and revisedLocation). The engine collects changes before serializing HTML; text fallback uses its diff segments.
Request ["html", "json"] for rendering and structured changes, or ["json"] for standalone source/revised block and alignment models. JSON-only output still refers to the HTML comparison representation in legacy location fields. Pass "hosted_viewer" in the output array to receive a secure embed token and hosted comparison URL.
What locations refer to
Legacy location fields describe normalized HTML, not source coordinates (they do not provide PDF page numbers or bounding boxes, Word revision IDs, JSON Pointer paths, byte offsets, or table-cell coordinates). Dual-anchored sourceLocation and revisedLocation fields add side-specific blockId, blockIndex, UTF-8 character offsets (charRange), and format-specific metadata (page, jsonPointer, tableCell, and moveId) when available.
Structured change fields
| Field | Type / presence | Meaning |
id | string; always | Document-order change ID, such as chg_000001. Stable across pages of the same comparison. They are not HTML element IDs, DOM selectors, or identifiers guaranteed to survive a new comparison. |
type | string; always | insert, delete, replace, format, link, block_insert, block_delete. Adjacent deletion/insertion groups can form a replacement. |
alignmentId | string; optional | Identifier of the aligned block pair (e.g., aln_000002) in alignment containing this change. |
sourceLocation / revisedLocation | object; conditional | Dual-anchored locations on the original (source) and revised document models: blockId, blockIndex, blockType, charRange (UTF-8 start/end within block text), and optional headingPath, sectionTitle, page, jsonPointer, tableCell, or moveId. Insertions omit sourceLocation; deletions omit revisedLocation. |
location.blockIndex | integer; always | Block counter in traversal of the HTML comparison representation. DOM blocks start at 1; 0 can indicate a change outside a recognized block. Not a source paragraph number or character offset. |
location.blockType | string; optional | HTML block type, such as p or h1. Unavailable in text fallback. |
location.headingPath / sectionTitle | array of strings / string; optional | Surrounding headings and nearest heading title in the HTML representation. Empty context is omitted. |
before / after | object; conditional | Insertions omit before; deletions omit after. |
before.text / after.text | string; on present fragments | Changed text, usable by agents without parsing HTML. |
before.html / after.html | string; on present fragments | HTML of the changed content, not a full source document. |
before.format / after.format | object; optional | Semantic or presentation formatting when available. |
before.href / after.href | string; optional | Link destination when available; omitted in text fallback. |
Use source, revised, alignment, and fragment text to inspect or render side-by-side changes without parsing merged HTML. Request HTML too when rendering a single-column redline.
Structured JSON page fields
| Field | Type / presence | Meaning |
schemaVersion | string; always | "1"; independent of the outer API schema version. |
comparisonId | string; optional | Stored comparison identifier. |
format / engine / fallback / attributeMode | metadata; always | Input format and engine behavior. Locations still refer to HTML representation. |
summary | object; always | Whole-comparison counts: changeCount, insertions, deletions, replacements, formatChanges, linkChanges, blockInsertions, blockDeletions. |
source / revised | object; optional | Side-specific document models with whole-side blockCount and blocks (id, index, type, text, spans) for changed blocks on the page plus 1 leading and 1 trailing context block. |
alignment | array; optional | Aligned block pairs (id, status, sourceBlockId, revisedBlockId, changeIds, segments) for the current page’s changed and context blocks. |
changes | array; always | Changes on the current page. |
totalChanges | integer; always | Number of changes in the complete comparison. |
offset / limit | integer; always | Zero-based page offset; default limit 100, maximum 500. |
nextOffset | integer; optional | Next page offset; omitted when there are no further changes. |
Example: both outputs (response excerpt)
{
"cssUrl": "https://dev.redline.bookcicle.com/styles/v1/redline.css",
"html": "<h1>Terms</h1><p>Pay within <del>30</del><ins>45</ins> days.</p>",
"json": {
"schemaVersion": "1",
"comparisonId": "cmp_example",
"format": "html",
"engine": "dom",
"fallback": false,
"attributeMode": "semantic",
"summary": {
"changeCount": 1,
"insertions": 0,
"deletions": 0,
"replacements": 1,
"formatChanges": 0,
"linkChanges": 0,
"blockInsertions": 0,
"blockDeletions": 0
},
"source": {
"blockCount": 2,
"blocks": [
{ "id": "src_b_000001", "index": 1, "type": "h1", "text": "Terms", "spans": [{ "text": "Terms", "charStart": 0, "charEnd": 5 }] },
{ "id": "src_b_000002", "index": 2, "type": "p", "headingPath": ["Terms"], "sectionTitle": "Terms", "text": "Pay within 30 days.", "spans": [{ "text": "Pay within 30 days.", "charStart": 0, "charEnd": 19 }] }
]
},
"revised": {
"blockCount": 2,
"blocks": [
{ "id": "rev_b_000001", "index": 1, "type": "h1", "text": "Terms", "spans": [{ "text": "Terms", "charStart": 0, "charEnd": 5 }] },
{ "id": "rev_b_000002", "index": 2, "type": "p", "headingPath": ["Terms"], "sectionTitle": "Terms", "text": "Pay within 45 days.", "spans": [{ "text": "Pay within 45 days.", "charStart": 0, "charEnd": 19 }] }
]
},
"alignment": [
{ "id": "aln_000001", "status": "unchanged", "sourceBlockId": "src_b_000001", "revisedBlockId": "rev_b_000001", "segments": [{ "op": "equal", "text": "Terms" }] },
{ "id": "aln_000002", "status": "modified", "sourceBlockId": "src_b_000002", "revisedBlockId": "rev_b_000002", "changeIds": ["chg_000001"], "segments": [{ "op": "equal", "text": "Pay within " }, { "op": "delete", "text": "30" }, { "op": "insert", "text": "45" }, { "op": "equal", "text": " days." }] }
],
"changes": [{
"id": "chg_000001",
"type": "replace",
"alignmentId": "aln_000002",
"location": {
"blockIndex": 2,
"blockType": "p",
"headingPath": ["Terms"],
"sectionTitle": "Terms"
},
"sourceLocation": {
"blockId": "src_b_000002",
"blockIndex": 2,
"blockType": "p",
"charRange": { "start": 11, "end": 13 },
"headingPath": ["Terms"],
"sectionTitle": "Terms"
},
"revisedLocation": {
"blockId": "rev_b_000002",
"blockIndex": 2,
"blockType": "p",
"charRange": { "start": 11, "end": 13 },
"headingPath": ["Terms"],
"sectionTitle": "Terms"
},
"before": { "text": "30", "html": "30" },
"after": { "text": "45", "html": "45" }
}],
"totalChanges": 1,
"offset": 0,
"limit": 100
},
"changesUrl": "https://api.bookcicle.com/v1/redline/comparisons/cmp_example/changes?token=EXAMPLE_PRIVATE_TOKEN"
}
Metadata, fallback, and pagination
Text fallback omits heading, block-type, formatting, and link context. Use before.text and after.text.
Follow nextOffset by GETting changesUrl with &offset=<nextOffset>&limit=100. Keep its private token. No nextOffset means the last page; the summary covers the whole comparison.
DOCX/PDF return this schema in content_complete.contentDiff.json, with contentDiff.changesUrl. Word Track Changes uses separate revision IDs and counts.
Documents API (DOCX & PDF Early Access)
Direct small-document input
For DOCX or PDF pairs up to 750 KiB combined file bytes, send both files as base64 in the comparison request. This skips upload authorization and file transfer. Larger pairs use upload IDs. Both sources must use the same input method.
{
"format": "pdf",
"original": { "dataBase64": "<base64 original file bytes>", "contentType": "application/pdf" },
"revised": { "dataBase64": "<base64 revised file bytes>", "contentType": "application/pdf" },
"mode": "auto",
"output": ["html", "json"],
"metrics": true
}
Word exports require authentication. Anonymous callers may request an HTML comparison within quota. If an anonymous request also asks for track_changes, the stream emits track_changes_failed with code export_authentication_required, then continues the HTML comparison and normal completion without an export job. Supply an authenticated Bearer token to create an export or retrieve its download URL. Replaying a cached anonymous comparison does not restore export access.
Upload two DOCX files or two searchable PDFs, then stream progress with SSE. Request HTML, JSON, or both; JSON changes use the same native comparison as the HTML response. Native Word Track Changes is default off and DOCX-only. Output fields and options are listed below.
Document comparison request
| Field | Type | Required / default | Meaning |
format | string | Required | docx or pdf; both files must use the same format. |
original.uploadId / revised.uploadId | string | Required for uploaded inputs | Upload IDs from the upload endpoint. Legacy originalUploadId / revisedUploadId aliases are also accepted. |
original.dataBase64 / revised.dataBase64 | string | Required for direct inputs | Base64 raw file bytes; use both sources together instead of upload IDs. Public limit: 750 KiB combined raw bytes. |
original.contentType / revised.contentType | string | Optional for direct inputs | Declared MIME type must match the selected document format. |
mode | string | "auto" | auto / word / block for the HTML comparison engine. |
attributeMode | string | "semantic" | semantic / presentation for HTML attribute comparison. |
output | array of strings | ["html"] | ["html"], ["json"], ["html", "json"]. Pass "hosted_viewer" in the output array to receive a secure embed token and hosted comparison URL (contentDiff.comparisonId, contentDiff.hostedUrl; alias: viewer). Native Word Track Changes is default off; pass "track_changes" in the output array to queue background generation of a .docx file with native Word tracked revisions. |
pageRendering | boolean | false | Return source-page PDFs after the text diff. Applies to both formats; omission and false skip all page rendering. |
metrics | boolean | false | Set true to include server processing metrics in content_complete and complete, including totalMs and downloadMs. |
title | string | Optional | Display title for stored comparisons. |
stream | boolean | false | Set true for provisional blocks and early canonical text. See Progressive streaming. |
Document content_complete event
| Field | Type / presence | Meaning |
requestId / format | string; always | Request identifier and source document format. |
units | integer; always | Comparison’s metered units. |
contentDiff.html | string; HTML requested | HTML rendered from the semantic document diff. |
contentDiff.cssUrl | URL string; always | Same rendering contract as text API cssUrl; current environment’s public v1 CSS. |
contentDiff.json | object; JSON requested | Same structured JSON page schema as the text API. |
contentDiff.changesUrl | URL string; JSON requested | Private pagination URL; relative paths resolve against the API origin. |
contentDiff.engine / fallback | string / boolean; always | Comparison engine and fallback status. |
contentDiff.comparisonId / hostedUrl | string / URL; stored output | Stored comparison identifier and private retrieval URL. hostedUrl retrieves JSON containing HTML; it is not a raw HTML page. |
contentDiff.earlyAccess | boolean; optional | Early Access status when supplied. |
Why the HTML redline and Word Track Changes can differ
The semantic document diff supplies HTML; Word exports compare the original DOCX pair independently.
Grouping, counts, and revision IDs may differ. mode does not configure the Word Track Changes comparator. Review each output against the source files.
Rendered source pages
Set pageRendering: true for page previews. The stream sends content_complete first, then page_rendering_complete with private PDF URLs, each side’s page count, the renderer and renderMs. URLs expire after 15 minutes; stored artifacts expire after 24 hours.
event: page_rendering_complete
data: {"schemaVersion":"redline.pages.v1","renderer":"<renderer identifier>","renderMs":210,"pages":[{"side":"original","pdfUrl":"<private URL>","pageCount":2,"expiresInSeconds":900},{"side":"revised","pdfUrl":"<private URL>","pageCount":3,"expiresInSeconds":900}]}
PDF previews preserve source bytes, page sizes and rotation. DOCX preview layout depends on available fonts; exact Word pagination is not guaranteed. The UI displays one page per side and highlights unambiguous text changes. The text comparison contains every supported change, including repeated text and style edits.
Review actions: Rendered pages are read-only. Use the interactive HTML review viewer for Accept/Reject, or review native DOCX Track Changes in Word. Review decisions do not update the source-page previews.
page_rendering_failed leaves the successful text diff available. Rendering is bounded to 1,000 pages, 32 MiB per PDF and 30 seconds of DOCX layout per side, within a 55-second request budget. Document comparison screens enable pages; the playground starts off and offers a checkbox.
Stream completion: Require both content_complete and complete; HTTP 200 only opens the stream. error means comparison failure. track_changes_failed and page_rendering_failed affect only their optional outputs.
Metrics: Use content_complete.metrics.totalMs for the semantic result and complete.metrics.totalMs for all requested work. comparisonMs measures server time through the semantic result and stays unchanged in final completion. pageRenderingMs measures optional page preparation and storage, including failed attempts; it is zero before rendering or when disabled. diffMs measures only the diff engine. renderMs in a successful page event measures page preparation and storage. Direct inputs report downloadMs: 0; upload transfer and browser painting are excluded.
Upload and stream example
- For the upload path, presign and upload both documents via
POST /v1/redline/uploads.
- Stream comparison via SSE with upload references:
POST /v1/redline/documents
Authorization: Bearer bc_live_...
Content-Type: application/json
Accept: text/event-stream
{
"format": "docx",
"original": {
"uploadId": "upl_orig_123"
},
"revised": {
"uploadId": "upl_rev_456"
},
"mode": "auto",
"output": ["html", "track_changes"],
"stream": true
}
- Receive ordered real-time SSE progress events with self-contained job polling URLs:
event: accepted
data: {"requestId":"req_abc"}
event: validating
data: {"format":"docx"}
event: converting
data: {"side":"original"}
event: converting
data: {"side":"revised"}
event: comparing
data: {"mode":"auto"}
event: content_block
data: {"comparisonId":"cmp_abc","sequence":0,"html":"<p>Provisional changed paragraph…</p>","provisional":true}
event: text_ready
data: {"comparisonId":"cmp_abc","html":"<p>Complete canonical redline…</p>","engine":"document","fallback":false,"provisional":false}
event: content_complete
data: {"requestId":"req_abc","format":"docx","contentDiff":{"html":"...","engine":"document","fallback":false},"units":2}
event: track_changes_queued
data: {"jobId":"job_789","status":"queued","pollUrl":"https://api.bookcicle.com/v1/redline/jobs/job_789","artifactType":"track_changes_docx"}
event: complete
data: {"requestId":"req_abc","status":"complete","jobs":[{"type":"track_changes_docx","jobId":"job_789","pollUrl":"https://api.bookcicle.com/v1/redline/jobs/job_789"}],"jobId":"job_789","units":2}
Progressive streaming
Rollout status, October 10, 2026: progressive streaming is verified on TEST. Confirm support in your target environment before enabling it. Examples use the production API origin; replace it with https://test.api.bookcicle.com to test this rollout.
Send the JSON boolean stream: true and Accept: text/event-stream. HTML, Markdown, JSON and plain-text comparisons use POST /v1/redline/html. DOCX/PDF use POST /v1/redline/documents, with either uploaded references or supported direct inputs.
For snippets, omitted or stream: false keeps the ordinary JSON response; the Accept header alone does not enable streaming. Documents always return SSE progress; their stream flag adds progressive text events without changing the existing final contract.
| Event | Meaning and client action |
content_block | Provisional HTML from the diff engine: html, zero-based sequence, comparisonId, provisional: true. Append to a temporary preview in sequence. Block boundaries are not network chunks, pages or stable change IDs. |
text_ready | Complete canonical HTML with provisional: false. Replace the whole provisional preview; do not append it. Text may be shown before storage, page previews or export jobs finish. Extra fields depend on the endpoint. |
content_complete | Documents only: the established semantic result in contentDiff, including requested JSON and available artifact information. |
complete | Terminal success. For snippets, data is the ordinary comparison response object. For documents, it is the existing final request/job envelope. Requested exports may still require polling their job URLs. |
error | Comparison failure, even after HTTP 200 or a preview. Do not treat a partial preview as a completed comparison. |
Blocks are emitted as engine work becomes available; validation, conversion and alignment still take time. A replay or some engine paths may send canonical text without any blocks. There is no guaranteed first-block deadline or one-event-per-word typing cadence.
Read a streaming POST response using Fetch or an HTTP streaming client. Decode UTF-8 incrementally, preserve incomplete lines across reads, join multiple data lines, and dispatch each blank-line-delimited SSE event. Network chunks can contain partial or multiple events. A closed connection without complete is incomplete. Handle page_rendering_failed and track_changes_failed separately; successful text remains available.
Keep preview state read-only. Use canonical results and final completion before enabling save/export or persisting change IDs. Aborting Fetch closes the stream; it is not a guarantee that admission, accounting or already-started work was undone. Retry with the applicable replay/idempotency behavior below.
Streaming snippet examples
curl
# Requires curl and jq. Set REDLINE_API_KEY first.
api_base="https://api.bookcicle.com"
api_post() {
body="$2"
curl --fail-with-body -sS -N "$api_base$1" \
-H "Authorization: Bearer $REDLINE_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: ${3:-application/json}" \
--data-binary "$body"
}
payload='{"original":"<p>One year.</p>","revised":"<p>Two years.</p>","mode":"auto","format":"html","output":["html","json"],"stream":true}'
if [ "$(printf '%s' "$payload" | wc -c)" -gt 819200 ]; then
staging=$(mktemp -d) || exit 1
trap 'rm -rf "$staging"' EXIT
upload_text() {
field="$1"
filename="$staging/$field.txt"
printf '%s' "$payload" | jq -j --arg field "$field" '.[$field]' > "$filename" || return
info=$(api_post /v1/redline/uploads "$(jq -nc --arg filename "$field.txt" \
--argjson sizeBytes "$(wc -c < "$filename")" \
'{filename:$filename,contentType:"text/plain; charset=utf-8",sizeBytes:$sizeBytes}')") || return
url=$(printf '%s' "$info" | jq -r .uploadUrl)
curl --fail-with-body -sS -o /dev/null -T "$filename" "$url" -H 'Content-Type: text/plain; charset=utf-8' || return
printf '%s' "$info" | jq -r .uploadId
}
original_id=$(upload_text original) || exit 1
revised_id=$(upload_text revised) || exit 1
payload=$(printf '%s' "$payload" | jq -c --arg original "$original_id" --arg revised "$revised_id" \
'.original={uploadId:$original} | .revised={uploadId:$revised}') || exit 1
fi
api_post /v1/redline/html "$payload" text/event-stream
JavaScript (Node.js)
const apiBase = "https://api.bookcicle.com";
const apiKey = process.env.REDLINE_API_KEY;
async function postApi(path, payload, accept = "application/json") {
if (path === "/v1/redline/html" && new TextEncoder().encode(JSON.stringify(payload)).length > 819200) {
async function upload(text, filename) {
const bytes = new TextEncoder().encode(text);
const contentType = "text/plain; charset=utf-8";
const info = await (await postApi("/v1/redline/uploads", { filename, contentType, sizeBytes: bytes.length })).json();
const result = await fetch(info.uploadUrl, { method: "PUT", headers: { "Content-Type": contentType }, body: bytes });
if (!result.ok) throw new Error("Upload returned " + result.status);
return { uploadId: info.uploadId };
}
const [original, revised] = await Promise.all([upload(payload.original, "original.txt"), upload(payload.revised, "revised.txt")]);
payload = { ...payload, original, revised };
}
const body = JSON.stringify(payload);
const response = await fetch(apiBase + path, {
method: "POST",
headers: {
"Authorization": "Bearer " + apiKey,
"Content-Type": "application/json",
"Accept": accept
},
body
});
if (!response.ok) throw new Error("API returned " + response.status);
return response;
}
async function consumeSSE(response, onEvent, requireDocument = false) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "", event = "message", data = [], completed = false, semantic = false;
function line(value) {
value = value.replace(/\r$/, "");
if (value === "") {
if (data.length) {
const payload = JSON.parse(data.join("\n"));
if (event === "error") throw new Error(payload.message || payload.code || "Comparison failed");
if (event === "complete") completed = true;
if (event === "content_complete") semantic = true;
onEvent(event, payload);
}
event = "message"; data = [];
} else if (value.startsWith("event:")) event = value.slice(6).trim();
else if (value.startsWith("data:")) data.push(value.slice(5).replace(/^ /, ""));
}
try {
while (true) {
const { done, value } = await reader.read();
buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });
let end;
while ((end = buffer.indexOf("\n")) >= 0) {
line(buffer.slice(0, end)); buffer = buffer.slice(end + 1);
}
if (done) break;
}
if (!completed || (requireDocument && !semantic)) throw new Error("Stream ended before complete; result is incomplete");
} finally { await reader.cancel(); }
}
// Blocks are provisional. Replace their preview with canonical text_ready HTML.
function showEvent(event, data) {
if (event === "content_block") console.log("Preview block:", data.html);
if (event === "text_ready") console.log("Canonical text:", data.html);
if (event === "content_complete") console.log("Document result:", data.contentDiff.html);
if (event === "complete" && data.html) console.log("Final text:", data.html);
// Save/export only once complete arrives; handle optional-output events separately.
}
const response = await postApi("/v1/redline/html", {
"original": "<p>One year.</p>",
"revised": "<p>Two years.</p>",
"mode": "auto",
"format": "html",
"output": [
"html",
"json"
],
"stream": true
}, "text/event-stream");
await consumeSSE(response, showEvent);
Python
import json, os, requests
from pathlib import Path
api_base = "https://api.bookcicle.com"
api_key = os.environ["REDLINE_API_KEY"]
def post_api(path, payload, stream=False):
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
if path == "/v1/redline/html" and len(body) > 819200:
def upload_text(text, filename):
content = text.encode("utf-8")
content_type = "text/plain; charset=utf-8"
info = post_api("/v1/redline/uploads", {
"filename": filename, "contentType": content_type, "sizeBytes": len(content)
}).json()
result = requests.put(info["uploadUrl"], data=content, headers={"Content-Type": content_type}, timeout=90)
result.raise_for_status()
return {"uploadId": info["uploadId"]}
payload = {**payload, "original": upload_text(payload["original"], "original.txt"),
"revised": upload_text(payload["revised"], "revised.txt")}
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
response = requests.post(api_base + path, headers={
"Authorization": "Bearer " + api_key,
"Content-Type": "application/json",
"Accept": "text/event-stream" if stream else "application/json",
}, data=body, stream=stream, timeout=90)
response.raise_for_status()
return response
def sse_events(response, require_document=False):
event, data, completed, semantic = "message", [], False, False
for raw in response.iter_lines():
line = raw.decode("utf-8")
if not line:
if data:
payload = json.loads("\n".join(data))
if event == "error":
raise RuntimeError(payload.get("message") or payload.get("code") or "Comparison failed")
completed = completed or event == "complete"
semantic = semantic or event == "content_complete"
yield event, payload
event, data = "message", []
elif line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
data.append(line[5:].removeprefix(" "))
if not completed or (require_document and not semantic):
raise RuntimeError("Stream ended before complete; result is incomplete")
payload = json.loads("{\"original\":\"<p>One year.</p>\",\"revised\":\"<p>Two years.</p>\",\"mode\":\"auto\",\"format\":\"html\",\"output\":[\"html\",\"json\"],\"stream\":true}")
with post_api("/v1/redline/html", payload, stream=True) as response:
for event, data in sse_events(response):
if event in ("content_block", "text_ready", "complete"):
print(event, data.get("html", ""))
Cache behavior
Cache policy for this release, October 10, 2026. Older deployed versions may still use the previous shared-text cache policy; verify the release running in your target environment.
| Operation | Reuse policy |
| Text/snippet comparisons | No cross-caller shared diff cache, at any input size. Caller-scoped replay remains enabled for eligible identical requests; a successful replay returns prior content without charging another comparison unit. Replay is best effort and has request/result size bounds. |
| Document semantic text | No shared text-diff cache. Durable caller-scoped idempotency remains separate: use the same Idempotency-Key and exact request to retry supported developer requests. The key does not permit a different caller to reuse your identity or export permissions. |
| DOCX page previews | With pageRendering: true, a pair is eligible only after the renderer confirms at least 32 pages on each document. A 32/31 pair is ineligible. This caches source-page PDF artifacts, not semantic changes or Word Track Changes. |
| PDF page previews | Existing PDF source pages are used directly; the shared DOCX page-rendering cache does not apply. |
The DOCX page cache keys both source contents and the rendering contract. Cache metadata is encrypted and expires after one hour; PDF bytes remain in private artifact storage. A hit copies artifacts into the new comparison scope and signs fresh links. Caller tokens, signed URLs, account usage and change decisions are never shared. Page reuse does not waive a new comparison’s normal admission or quota charge.
Word’s saved page count can skip lookup for known small inputs; actual rendered counts alone decide admission. Unknown counts may incur a lookup during Pages preparation after Text is available. Unavailable encryption/cache configuration skips caching. Cache corruption, missing artifacts or copy failures fall back to normal rendering.
Snippet clients can send Cache-Control: no-store or no-cache to force fresh computation and skip comparison-cache population; ordinary quota accounting still applies. For documents, omitting Idempotency-Key disables that retry mechanism but does not disable the optional page-artifact cache. Neither streaming nor HTTP cache headers bypass authentication, rate limits or quota.
API-key identity lookups are separate from comparison caching. Each running service worker can retain a key identity for up to 60 seconds, so revocation can take time to propagate across workers. After revoking a key, verify that requests reject it with api_key_revoked. Comparison reuse does not override a revoked-key denial, and quota decisions remain authoritative.
Caching is an optimization, not a latency guarantee. Small diffs may be cheaper to recompute than a remote lookup. The 32-page policy is a conservative admission choice; density, fonts, layout and actual cache latency affect savings. Use metrics to distinguish early Text readiness from full page preparation.
Performance & Expectations
Live DEV measurements cover native DOCX/PDF → HTML/JSON and HTML, Markdown, TXT and JSON snippets. Upload cases use files already uploaded; direct/inline cases submit source bytes. DOCX sizes are nominal fixture labels; PDF counts describe source pages.
Historical developer API fixture estimates from October 5, 2026, using warm requests with comparison caching bypassed. These are not click-to-review targets for the customer workspace. Browser upload, network time, and customer save preparation require separate measurement; page preparation is shown separately.
Expected Server Processing
Typical warm processing estimates for uncached comparisons. Page Rendering is optional server preparation, excludes browser drawing and is additional to comparison time. Rendering estimates apply to the same fixture across input methods.
What the timings include
Published server timings use metrics.totalMs and exclude upload authorization, file transfer, network transit and browser rendering. They include source retrieval, parsing, comparison, HTML/JSON response preparation and normal request work. Baseline rows skip optional page preparation.
Comparisons bypass caches: snippets require Cache-Control: no-store and server confirmation; documents omit idempotency keys. Authentication caching remains enabled. These fixture results are not a promise for every document under 10 pages. Density, cold starts, structure and outputs affect timing.
Measure client completion separately. Its difference from server processing includes transport, buffering and client handling; it is not a measurement of network time alone.
The full report includes larger/denser inputs, concurrent requests and failed qualifications. Failed output gets no timing claim. Benchmark report · Raw evidence · Page-flow validation.
Capability Matrix
| Capability |
Word DOCX |
Searchable PDF |
Plain Text / Markdown / JSON |
| Searchable text & content comparison |
Yes |
Yes |
Yes |
| Punctuation & word-level diffing |
Yes |
Yes |
Yes |
| Source document structure |
No guarantee |
No guarantee |
No guarantee |
| Text inside tables |
When extracted |
When extractable |
As text |
| Source formatting preservation |
No guarantee |
No guarantee |
No guarantee |
Hyperlink destination changes (href) |
When preserved by conversion |
Not guaranteed |
Markdown links |
Native Word Track Changes export (.docx) |
Yes (Async worker) |
No (HTML diff only) |
No |
| Scanned / Image-only PDF (OCR) |
No (Rejected) |
No (Rejected as non-text) |
N/A |
| Visual / Pixel / Layout comparison |
No |
No |
No |
| Deterministic large-document fallback |
Yes |
Yes |
Yes |
What Redline Compares vs. What It Excludes
Redline draws a principled boundary between semantic document revisions and cosmetic rendering artifacts:
- Compared: Words, numbers, symbols, punctuation, paragraph boundaries, headings, bullet/numbered lists, blockquotes, table contents, semantic inline tags (
<strong>, <em>, <u>, <s>, <sup>, <sub>, <mark>), and hyperlink URLs.
- Excluded: Font family typography (e.g. Arial vs Calibri), font size, font color, background shading, margin dimensions, page size (Letter vs A4), orientation, multi-column flows, line spacing, dynamic page break shifts, decorative headers/footers, and embedded shapes/drawing canvases.
Deterministic Complexity Fallbacks
When document structural complexity exceeds the DOM processing envelope (>512 KiB HTML, >1,500 block elements, or >12,000 total tags), the engine automatically falls back to a linear text-stream comparison. This preserves complete change detection for text, numbers, punctuation, and paragraphs in tens of milliseconds, keeping complex comparisons within processing limits.
Operational Limits
| Limit | Value |
| Direct DOCX/PDF input | 750 KiB combined. Larger pairs use uploads. |
| Upload size | 10 MiB combined, uncompressed. Without signing in: 5 MiB per file. |
| Billing unit | 100 KiB (102,400 bytes) of combined content. |
| Server timeout | 60 seconds. |
| ZIP safeguards | 100:1 maximum expansion ratio; 1,000 entries maximum. |
Large File Uploads
For text formats, the inline limit is 800 KiB (819200 bytes) for the complete UTF-8 JSON request, including escaping, field names, and options. Measure the serialized request, not just the document text. Larger comparisons use uploads.
- Request
POST /v1/redline/uploads for each input with filename, contentType, and its UTF-8 sizeBytes. Text inputs use text/plain; charset=utf-8. Each response returns uploadId and uploadUrl.
- PUT the raw input bytes to each
uploadUrl, using the same content type. Limits are 5 MiB per anonymous input and 10 MiB per authenticated input. Use the same identity to authorize uploads and compare them.
- For HTML, Markdown, JSON, or plain text, call
POST /v1/redline/html with upload references. Keep the same mode, format, and output options. DOCX and PDF continue to use POST /v1/redline/documents.
{
"original": { "uploadId": "upl_12345678-1234-4234-9234-123456789abc" },
"revised": { "uploadId": "upl_87654321-4321-4321-9321-abcdef123456" },
"mode": "word",
"format": "html",
"output": ["html"]
}
For text formats, check the complete serialized request against the 800 KiB limit. For PDF/DOCX, choose direct input up to 750 KiB combined raw file bytes and uploads above that threshold. Upload IDs belong to the identity that created them; they are not arbitrary storage paths.
Hosted Viewer & Embeds
To enable host controls and review callbacks, an authenticated comparison creator must include "embedOrigin": "https://your-app.example" when creating the hosted comparison. This authorizes that exact origin for this comparison. Embedding without a host grant remains available; cross-origin controls and callbacks stay disabled. Referrers and iframe query parameters cannot grant access.
Redline can generate a hosted, access-controlled text comparison viewer or provide responsive iframe embed codes without needing to build custom review UI in your application.
Pass "hosted_viewer" in the output array to receive a secure embed token and hosted comparison URL:
<iframe
src="https://redline.bookcicle.com/embed/cmp_9f82d1?token=cmp_tok_abc123&theme=system&collapse=true"
width="100%"
height="600"
loading="lazy"
style="border:1px solid #e2e8f0; border-radius:8px;"
></iframe>
Supported iframe query parameters
| Parameter | Description |
theme=system|light|dark | Forces color scheme. |
view=unified|split | Sets initial layout. |
collapse=true|false | Collapses or expands long unchanged text runs (defaults to true). |
controls=false | Suppresses viewer top navigation chrome. |
bg=transparent | Removes background fill for seamless container embedding. |
review=true|false | Enables interactive track changes review with inline Accept/Reject buttons and progress tracking (Creator & Creator Pro). |
Interactive Track Changes Review Mode (Creator & Creator Pro)
Redline enables interactive change-by-change review inside embedded viewers. End-users can accept or reject individual insertions and deletions or bulk-resolve changes using Accept All and Reject All. Every action dispatches bidirectional window.postMessage events to your host application.
Unified Layout Lock: When Review Mode is active (review=true or via redline:startReview), the viewer is automatically locked into Unified layout. The Split layout option is explicitly disabled in the UI and via postMessage commands during active review to preserve synchronous inline Accept/Reject action buttons, change boundaries, and DOM hierarchy. Split mode re-enables once review is completed or exited.
Review Completion & Clean Block Pruning: When resolving changes, rejected insertions (or accepted deletions) that span entire list items (<li>) or paragraphs (<p>) automatically prune empty container nodes so no orphaned bullets or blank lines remain. Once all changes are reviewed one-by-one, Accept All and Reject All are dismissed leaving Finish Review as the sole action, alongside a floating bottom-right completion prompt ensuring users never miss the final export step.
Bidirectional PostMessage Protocol
Listen for review callbacks in your host container:
window.addEventListener("message", (event) => {
const { type, ...payload } = event.data || {};
if (type === "redline:changeResolved") {
// Fired whenever an individual change is accepted or rejected
console.log(`Change ${payload.changeId} ${payload.action}ed. Remaining: ${payload.remaining} of ${payload.total}`);
} else if (type === "redline:reviewComplete") {
// Fired when all changes are resolved or Finish Review is clicked
console.log("Review complete! Final resolved HTML:", payload.resolvedHtml);
console.log("Accepted changes:", payload.accepted);
console.log("Rejected changes:", payload.rejected);
}
});
Host applications can also programmatically resolve changes and manage viewer layout:
// Start review mode
iframe.contentWindow.postMessage({ type: "redline:startReview" }, "https://redline.bookcicle.com");
// Accept or reject a specific change ID
iframe.contentWindow.postMessage({ type: "redline:acceptChange", changeId: "chg-1" }, "*");
iframe.contentWindow.postMessage({ type: "redline:rejectChange", changeId: "chg-2" }, "*");
// Bulk resolve remaining changes
iframe.contentWindow.postMessage({ type: "redline:acceptAll" }, "*");
iframe.contentWindow.postMessage({ type: "redline:rejectAll" }, "*");
// Query live review state
iframe.contentWindow.postMessage({ type: "redline:requestState" }, "*");
// Change viewer appearance
iframe.contentWindow.postMessage({ type: "redline:setView", view: "split" }, "*");
iframe.contentWindow.postMessage({ type: "redline:setTheme", theme: "dark" }, "*");
iframe.contentWindow.postMessage({ type: "redline:setCollapse", collapsed: false }, "*");
Drop-in Redline Embed SDK (/scripts/v1/redline.js)
New subscriber-created private viewer links allow guest browser review without a reviewer account. The service supplies the review entitlement after validating the viewer link.
Review choices stay in the browser. The completion callback provides resolved HTML for your application to save or process. It does not save a Redline draft or change Word downloads or page previews.
Rather than writing raw <iframe> tags and manual postMessage listeners, include Redline's lightweight SDK script:
<script src="https://redline.bookcicle.com/scripts/v1/redline.js"></script>
<div id="redline-mount" style="min-height: 560px;"></div>
<script>
const viewer = Redline.embed({
container: "#redline-mount",
comparisonId: "cmp_demo_marketing",
review: true, // Enable interactive track changes review
theme: "system", // "system" | "light" | "dark"
view: "unified", // "unified" | "split"
onChangeResolved: (evt) => {
console.log(`Change ${evt.changeId} ${evt.action}ed (${evt.remaining} remaining)`);
},
onComplete: (evt) => {
console.log("Final approved document HTML:", evt.resolvedHtml);
// Persist or commit the approved document to your database
}
});
// Programmatic controls:
// viewer.accept("chg-1");
// viewer.reject("chg-2");
// viewer.acceptAll();
// viewer.setTheme("dark");
// viewer.destroy();
</script>
Hosted Embed Availability & Expiration (Adjustable TTL)
Pass "hosted_viewer" in the output array to receive a secure embed token and hosted comparison URL. Hosted viewer tokens and stored comparison files are intentionally ephemeral, designed for interactive diff review, approval gates, and PR previews. You can customize the availability window using the hostedViewerTtl parameter:
Hosted Embed Availability & Lifecycle Limits
| Property | Specification | Details |
token_ttl | 24h default (up to 72h) | The signed embed token (cmp_tok_<timestamp>_<signature>) expires 24 hours after issuance by default, or after your selected TTL. Requests beyond this duration return token_expired. |
creator_ttl | 24h, 32h options | Creator accounts can configure hostedViewerTtl to "24h" (default) or "32h". |
creator_pro_ttl | 24h, 32h, 48h, 72h options | Creator Pro accounts can configure hostedViewerTtl to "24h" (default), "32h", "48h", or "72h" for extended multi-day review workflows. |
storage_lifecycle | Matches token TTL | Stored HTML diffs and JSON changes remain accessible for the duration of the token TTL, scheduled for automated expiration cleanup thereafter. |
expiration_ui | Inline expired state | Expired comparison links return HTTP 404 and present a clear notice: Hosted viewer tokens expire after the configured TTL. Stored comparisons are scheduled to expire after the retention period. |
clock_skew | 60 seconds tolerance | Tokens timestamped more than 60 seconds into the future are rejected (token_issued_in_future) to prevent forged timestamps. |
demo_comparisons | Permanent / No expiration | Public sample comparisons (demo, sample, cmp_demo*) bypass token verification and never expire, useful for dev testing. |
Requesting a Hosted Comparison with Custom TTL
Pass output: ["html", "hosted_viewer"] and optional hostedViewerTtl (e.g. "32h" for Creator, or up to "72h" for Creator Pro):
// Request a hosted comparison with extended 48-hour availability (Creator Pro)
const response = await fetch("https://api.bookcicle.com/v1/redline/html", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.REDLINE_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
original: originalHtml,
revised: revisedHtml,
output: ["html", "hosted_viewer"],
hostedViewerTtl: "48h" // "24h" (default), "32h" (Creator); "48h", "72h" (Creator Pro)
})
});
const data = await response.json();
// data returns:
// - data.comparisonId: "cmp_a1b2c3d4e5f6..."
// - data.viewerToken: "cmp_tok_1740000000_abc123..." (HMAC-SHA256, active for 48h)
// - data.embedUrl: "https://redline.bookcicle.com/embed/cmp_...?token=cmp_tok_..."
// - data.viewerUrl: "https://redline.bookcicle.com/view/cmp_...?token=cmp_tok_..."
Long-Term Retention & Re-Hosting Strategies
- Permanent Archival: If your application needs long-term or audit-ready diff retention, store the returned
html fragment and structured json changes directly in your own database or object storage. Pair with Redline’s public, versioned stylesheet (https://redline.bookcicle.com/styles/v1/redline.css).
- Re-issuing Embed Links: To generate a new 24-hour viewer link for a past comparison, simply re-post the original and revised documents or upload IDs with
output: ["html", "hosted_viewer"]. Redline’s sub-100ms comparison engine generates a fresh 24-hour token instantly.
CSS Styling Contract
Redline HTML returns presentation-neutral markup with semantic tags. To use our default theme, link the stylesheet:
<link rel="stylesheet" href="https://redline.bookcicle.com/styles/v1/redline.css">
Load the returned cssUrl once in your rendering document. Document SSE uses contentDiff.cssUrl; the field is also present for JSON-only results.
Render the HTML fragment in an isolated preview. HTML fragments do not contain a stylesheet link; hosted viewers already load it.
The stylesheet is public and environment-specific. Viewer and artifact URLs are private; keep their returned tokens.
Or override specific selectors:
ins { background: #dcfce7; color: #166534; text-decoration: none; }
del { background: #fee2e2; color: #991b1b; text-decoration: line-through; }
Units & Quota Calculation
Text usage is metered by UTF-8 input bytes; PDF/DOCX usage counts raw file bytes:
units = ceil((original_bytes + revised_bytes) / 102400)
1 unit = 100 KiB combined raw input bytes; base64 encoding overhead is not billed. Direct PDF/DOCX pairs support 750 KiB combined raw file bytes; text-format inline requests support 800 KiB serialized JSON. Larger inputs use uploads. Document comparisons remain capped at 10 MiB combined; each anonymous file is capped at 5 MiB.
Error Codes
| Code | HTTP | Description |
export_authentication_required | 401 on export and job endpoints | Supply a valid authenticated Bearer token to export. In a document comparison stream, this code appears in a nonfatal track_changes_failed event; the HTML result and normal completion remain available within quota. |
unauthorized | 401 | Missing or invalid authentication. |
invalid_api_key | 401 | Key does not exist or has invalid prefix. |
api_key_revoked | 403 | Key has been explicitly revoked. |
quota_exceeded | 402 | Monthly plan allowance depleted. |
rate_limited | 429 | Plan rate limit or service admission capacity exceeded. Gateway throttles can instead return {"message":"Rate Exceeded."} without a code. |
inline_limit_exceeded | 413 | JSON request > 819200 bytes. Upload inputs and send upload IDs. |
document_too_large | 413 | Document exceeds 10 MiB maximum. |
document_too_complex | 422 | DOM tag or block density guard triggered. |
Troubleshooting payload signatures: If a direct request over 1 MiB fails with an authorization or signature mismatch error, ensure the x-amz-content-sha256 header contains the lowercase hex SHA-256 digest computed over the exact UTF-8 request body bytes.
Rate Limits
API requests are rate-limited based on your active plan tier:
| Plan | Rate Limit | Max Document Size |
| Basic | 30 requests / minute | 800 KiB JSON |
| Creator | 120 requests / minute | 5 MiB |
| Creator Pro | 300 requests / minute | 10 MiB |
When exceeded, endpoints return HTTP 429 Too Many Requests. Rate-limit headers vary by endpoint; anonymous viewer and job requests can be limited before authentication errors are returned. Honor Retry-After when supplied. If absent, use exponential backoff with full jitter (random delay from zero to min(30 seconds, 1 second × 2^attempt)), with at most five attempts; do not depend on every response containing x-ratelimit-limit, x-ratelimit-remaining, or x-ratelimit-reset.
Immutable inputs and idempotent retries
Generate one Idempotency-Key before submitting an operation. Reuse it with the same authenticated identity, endpoint and exact serialized request bytes. Supported operations are POST /v1/redline/html, POST /v1/redline/documents, customer comparisons and customer share creation. Keys contain 16–128 letters, digits, hyphens, underscores, dots or colons. Upload first and retain the resulting upload IDs and request body for retries.
A durable primary-region conditional claim admits the operation once, before quota charging or share creation. Concurrent retries receive HTTP 409 idempotency_in_progress and Retry-After: 1. Once complete, the same key returns the existing comparison/share IDs and retained result without another charge. Different inputs with the same key receive HTTP 409 idempotency_conflict. Authentication and rate limits still apply to replay.
Encrypted replies are retained for seven days. Their existing signed links keep their original expiry; use the result ID to request available current artifact links. After retention, HTTP 409 idempotency_expired prevents that key from executing again. Keys do not expire into permission to charge again.
A storage outage returns HTTP 503 idempotency_unavailable. If processing stops after admission, an uncertain operation stays blocked rather than being executed twice. Retry with the original key; if it remains unresolved, contact support with the request ID. Do not switch to a new key to recover an uncertain operation. A new key explicitly starts a new operation and can incur another charge.
For HTTP 429, honor Retry-After (seconds or an HTTP date) and otherwise apply the bounded jittered backoff above. Never change inputs or generate a new key inside a retry loop. Quota denials (HTTP 402), permission failures and input conflicts are final. Streaming responses may already have HTTP 200 when an SSE error event arrives: check the event code and treat early blocks as provisional until complete. After a disconnect or partial stream, only retry supported operations with the original key and body.
# Generate once per comparison, then preserve these values across attempts.
export REDLINE_IDEMPOTENCY_KEY="$(uuidgen)"
curl --fail-with-body -N https://api.bookcicle.com/v1/redline/html \
-H "Authorization: Bearer $REDLINE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $REDLINE_IDEMPOTENCY_KEY" \
--data-binary @comparison.json
Generated JavaScript and Python examples accept REDLINE_IDEMPOTENCY_KEY. Explicit idempotency is independent of optional diff caches, including Cache-Control: no-store; that directive skips comparison caching, not durable retry records.