Every route here is the same write or read the browser already makes — the MCP server calls the same ones for your agent. Use it directly when a script, a cron job, or your own tool needs the same thing.
Authentication
Create a key at htmlradar.com/settings under API keys. A key is hr_live_ followed by 40 hexadecimal characters, shown once — only a hash is stored, so it can’t be recovered if lost. Send it on every call:
Authorization: Bearer hr_live_…
Choose a scope when you create the key. Full can create, change, replace and revoke; read-only can only list, read activity and read what your documents say — a write from a read-only key comes back as a 403 naming the missing permission. A key that was never given the power to read document contents gets a 403 with a link where its owner approves it in one click; the same key then works. Neither scope can delete anything; that stays on the website, where a person types the confirmation.
A full-access key also carries the permission version its owner approved. A key made before the PATCH and attachment routes existed keeps everything it could do, and those routes answer it with a 403 permission_update_required and an update_url. The owner opens that address, signed in, and presses Approve; the same key then works, with nothing to replace. GET /api/v1/me reports it.
GET / api/ v1/ me
Is this key good, and what plan is it on. No parameters.
curl https://htmlradar.com/api/v1/me \ -H "Authorization: Bearer hr_live_…"
{
"user_id": "c1a2b3c4-…",
"email": "you@example.com",
"tier": "free",
"free_links_used": 1,
"free_links_cap": 2,
"key_read_only": false,
"team_read_only": false,
"reads_contents": true,
"permission_version": 1,
"current_permission_version": 2,
"update_available": true,
"update_url": "https://htmlradar.com/connect/update?key=…"
}On Pro, free_links_cap is null — there is no lifetime cap to report. key_read_only says whether this key can only read, and team_read_only whether the owner’s team has made connections read-only (null when that could not be checked). update_available is true when its owner has an update to approve at update_url: this full-access key was approved before the current permission version, or reads_contents is false, so it cannot read what a page says. Otherwise update_url is null.
GET / api/ v1/ documents
The account’s documents, newest first, with how many links point at each one. Same paging as above. The phishing screen’s score is deliberately left out — it’s an operator signal, not a customer number.
curl https://htmlradar.com/api/v1/documents \ -H "Authorization: Bearer hr_live_…"
{
"documents": [
{
"document_id": "22222222-2222-4222-8222-222222222222",
"title": "Q3 proposal",
"created_at": "2026-08-30T10:00:00.000Z",
"current_version": 3,
"share_count": 2
}
],
"next_before": null
}GET / api/ v1/ documents/ {id}/ html
A document’s current contents, so they can be changed and sent back to /replace. Every image embedded as a PNG, JPEG, GIF or WebP data URI comes back as a short reference, htmlradar-image: followed by 24 hexadecimal characters, so a page with hundreds of images fits in one answer. SVG images and everything else are returned as they are. A stored page over 20 MB is refused with a 422; replace it on the website. A short form over 5 MB is too large to return whole and is refused with a 422 too; add ?query=, 1 to 200 characters matched as literal text with letter case ignored, to get instead up to 20 passages of the page around the matches, and matches, how many there are. Those passages can then be changed with edits.
curl https://htmlradar.com/api/v1/documents/22222222-2222-4222-8222-222222222222/html \ -H "Authorization: Bearer hr_live_…"
{
"document_id": "22222222-2222-4222-8222-222222222222",
"title": "Asset library",
"version": 3,
"html": "<html>… <img src=\"htmlradar-image:3f9a0c1d2e4b5a6978c0d1e2\"> …</html>",
"image_references": 808,
"stored_bytes": 15204861
}GET / api/ v1/ shares/ {id}/ activity
Whether the link was opened, by whom, how long they read, how far they scrolled, and which sections they read, in deck order. Add ?include_detail=true for a nested detail object per viewer — country, city, device and referrer — off by default, since that’s a named person’s location and device.
curl "https://htmlradar.com/api/v1/shares/11111111-1111-4111-8111-111111111111/activity" \ -H "Authorization: Bearer hr_live_…"
{
"share_id": "11111111-1111-4111-8111-111111111111",
"url": "https://htmlradar.page/r/q3-proposal",
"opened": true,
"viewers": [
{
"label": "Halbrook",
"email": "marc@halbrook.vc",
"first_open": "2026-08-29T14:02:00.000Z",
"last_seen": "2026-08-29T14:09:00.000Z",
"active_seconds": 252,
"max_scroll": 87,
"sections": [
{ "title": "The Ask", "time_seconds": 161 },
{ "title": "Problem", "time_seconds": 48 }
],
"detail": { "country": "US", "city": "Austin", "device": "desktop", "referrer": null }
}
]
}label is the link’s own recipient_label, the same on every row — not the viewer’s name; detail only appears with include_detail. A link nobody has opened returns { "opened": false, "viewers": [] }.
GET / api/ v1/ documents/ {id}/ reading-records
The same reading the report shows, as a CSV file: one row per section per read, with the link’s label and address, the reader, whether their address was verified, when they opened it, how long they read, how far they scrolled, and their country and device where we recorded them. Add ?share= — an id, a slug or the whole link — to narrow it to one link. This is the download behind the Download CSV control on the read report.
curl -O -J "https://htmlradar.com/api/v1/documents/22222222-2222-4222-8222-222222222222/reading-records" \ -H "Authorization: Bearer hr_live_…"
link_label,link_slug,reader,verified,opened_at,reading_seconds,scroll_depth,country,device,section,section_seconds Halbrook,q3-proposal,marc@halbrook.vc,yes,2026-08-29T14:02:00.000Z,252,0.87,US,desktop,The Ask,161 Halbrook,q3-proposal,marc@halbrook.vc,yes,2026-08-29T14:02:00.000Z,252,0.87,US,desktop,Problem,48 Beta,beta-proposal,anonymous 1,no,2026-08-30T09:11:00.000Z,40,0.4,DE,mobile,The Ask,40
A reader who never typed an address appears as anonymous 1, numbered in the order they first appeared and stable across downloads; the browser fingerprint behind that number is never in the file. Your own previews and internal viewers are left out, exactly as they are on the read report. A document nobody has opened returns the header row and nothing else. One download carries up to 5,000 reads, oldest first; past that the response carries X-Reading-Records-Truncated: 1 and ?share= is the way to the rest. How long these records exist at all is your account’s retention setting, in Settings.
POST / api/ v1/ shares/ {id}/ revoke
Switches a link off. Reversible — send { "revoked": false } to put it back on; an empty body revokes.
curl -X POST "https://htmlradar.com/api/v1/shares/11111111-1111-4111-8111-111111111111/revoke" \ -H "Authorization: Bearer hr_live_…"
{
"share_id": "11111111-1111-4111-8111-111111111111",
"url": "https://htmlradar.page/r/q3-proposal",
"revoked": true,
"revoked_at": "2026-09-04T12:00:00.000Z"
}GET, POST / api/ v1/ documents/ {id}/ attachments
GET lists the files attached to a document, with the limits. Any key can read it.
POST attaches one file. A JSON request carries the file as base64, so a file is at most 5 MB through the API, the same ceiling as a document sent to POST /api/v1/shares; a larger one is added on the website, which takes up to 25 MB. The file types, the size and count limits (20 files, 100 MB in all) and the order of the writes are the website’s: the same code runs. It needs a full-access key at permission version 2 or later.
curl -X POST "https://htmlradar.com/api/v1/documents/22222222-2222-4222-8222-222222222222/attachments" \
-H "Authorization: Bearer hr_live_…" \
-H "Content-Type: application/json" \
-d '{ "filename": "pricing.pdf", "content_base64": "JVBERi0xLjQK…" }'{
"attachment_id": "44444444-4444-4444-8444-444444444444",
"document_id": "22222222-2222-4222-8222-222222222222",
"filename": "pricing.pdf",
"mime_type": "application/pdf",
"size_bytes": 48211
}DELETE / api/ v1/ documents/ {id}/ attachments/ {attachmentId}
Removes one file from a document. It removes the file and nothing else: the document, its links and its reading records stay. There is no route that deletes a document or a link; that stays on the website. It needs a full-access key at permission version 2 or later.
{
"attachment_id": "44444444-4444-4444-8444-444444444444",
"document_id": "22222222-2222-4222-8222-222222222222",
"filename": "pricing.pdf",
"removed": true
}POST / api/ v1/ documents/ {id}/ replace
Puts new content behind every link already sent — same addresses, same settings, same reading history, no second link. The new HTML runs through the same phishing screen as any upload; the previous version stays in the document’s history.
Send either the whole page in html, or edits: 1 to 200 pairs of text to find in the page /html returned and its replacement, applied in order, each found exactly once. Either may keep the image references; they are put back byte for byte, and one the current version does not hold is refused. With edits or references, expected_version is required: the version /html returned. If the document has changed since, nothing is changed and the 409 says which version is current.
curl -X POST "https://htmlradar.com/api/v1/documents/22222222-2222-4222-8222-222222222222/replace" \
-H "Authorization: Bearer hr_live_…" \
-H "Content-Type: application/json" \
-d '{ "html": "<html>…</html>", "expected_version": 3, "request_id": "q3-proposal-fix-1" }'{
"document_id": "22222222-2222-4222-8222-222222222222",
"version": 2,
"links_unchanged": true
}curl -X POST "https://htmlradar.com/api/v1/documents/22222222-2222-4222-8222-222222222222/replace" \
-H "Authorization: Bearer hr_live_…" \
-H "Content-Type: application/json" \
-d '{ "expected_version": 3, "edits": [{ "find": "Item 1043: $14.99", "replace": "Item 1043: $15.49" }] }'A conflict — someone else replaced or deleted the document mid-upload — returns a 409 rather than overwriting anything; read the current version and try again.
Send expected_version, the document’s current_version from GET /api/v1/documents or the version your last replace returned. It is optional, but without it the replace lands on whatever version is newest: a request retried after a lost answer, or sent from an old copy, can overwrite a newer version somebody else published. With it, a document that has moved on returns a 409 and nothing is replaced. Send a request_id (8 to 100 letters, digits, “-” or “_”) too, and a retry with the same one returns the first answer instead of replacing twice; POST /api/v1/shares takes one the same way.
Errors and rate limits
Every error is JSON with an error field:
| Status | error | When |
|---|---|---|
401 | invalid_api_key | The Authorization header is missing or the key is wrong. |
402 | free_limit_reached | The free plan’s two-link cap is reached; body has upgrade_url. |
403 | read_only_key | A read-only key called a route that creates, changes, revokes or replaces. |
403 | team_read_only | The key’s owner is on a company team whose administrator made keys and connections read-only. Only that administrator can change it. |
403 | permission_update_required | The key has not been given a power the route needs: editing a link, managing attachments or reading document contents. Its owner approves it in one click at update_url, and the same key then works. |
404 | not_found | Wrong id, or someone else’s — both read the same, on purpose. |
408 | request_timeout | The request body stopped arriving before it finished. |
409 | conflict | /replace only: the document changed first (another replace, a delete, or an expected_version that is no longer current). |
409 | share_changed | PATCH only: the link was changed after the version you read. Nothing was written. GET the link again and work out the change from what it shows now; do not resend the same body. |
413 | too_large | Over the 5 MB document cap. The body carries max_bytes. |
422 | validation | The body is missing a required field or one is the wrong shape. |
422 | team_rule | A rule set by the key owner’s company team (a required one-time code, company domains) refuses the change. The message names the rule. |
429 | rate_limited | Over an hourly budget. retry_after_seconds is in the body and the Retry-After header. |
500 | internal / | Something broke on our side. Nothing was written. |
Every limit is per hour, on a rolling window rather than the clock hour:
| Route | Budget |
|---|---|
GET / | 60/hour, per key |
POST / | 75/hour Pro, 30/hour free, per account — plus 120/hour per address |
GET / | 120/hour, per account |
GET and PATCH / | 120/hour, per account |
GET / | 300/hour, per key |
GET / | 30/hour, per account |
GET / | 75/hour Pro, 30/hour free, per account — plus 120/hour per address |
A missing or wrong key | 60/hour, per address |
The same calls from an agent
Everything above is also what the HTMLRadar MCP server calls, wrapped as tools an agent asks for in words — share_html is POST /api/v1/shares, get_share_activity is the activity route above. Call the API directly from a script; use the MCP server when an agent should call it for you.
The API stores nothing the dashboard doesn’t already store: the document, the link’s settings, and who opened it. Full detail is in the privacy policy.
Related: MCP server, create an API key, and security and data handling.