HTMLRadar · API

The HTMLRadar API

Updated 7 October 2026

The HTMLRadar API is the same set of calls the dashboard and the MCP server use to create tracked links, list them, read who opened one, change a link’s settings, manage a document’s files, revoke a link, and replace a document behind links you already sent. Authenticate with a key from Settings; every response below is exactly what the route returns.

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:

every request
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.

request
curl https://htmlradar.com/api/v1/me \
  -H "Authorization: Bearer hr_live_…"
response — 200
{
  "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.

POST /api/v1/shares

Turns HTML into a tracked link, or adds a second link to a document you already created. Provide exactly one of html, url or document_id.

FieldTypeDefaultConstraint
htmlstringone of html / url / document_id, requiredFull markup. Up to 5 MB.
urlstring—Accepted in the shape, but URL mode is not live on the API yet — returns a 422. Upload the HTML instead.
document_idstring—A second link on a document you already created.
titlestringthe document's <title>Shown on your dashboard only.
recipient_labelstringnoneWho the link is for, e.g. "Halbrook".
require_emailbooleantrueAsk for an email before the document opens.
verify_emailbooleanfalseMails a six-digit code to the address the reader types on the gate, and the document opens only once that code comes back. Requires require_email: true, or the call returns a 422.
passwordstringnoneExtra gate on top of the email gate. At least 8 characters.
allow_commentsbooleanfalseWhether readers can comment. Enabling requires the comments capability, the e-mail gate and a one-time code.
lock_deckbooleantrueBlocks save and print and adds a watermark.
allowed_email_domainsstring[]noneOnly these domains may open it. Up to 500. Requires require_email: true, or the call returns a 422.
allowed_emailsstring[]noneOnly these exact addresses may open it — the same separate list the share form keeps beside the domains. Trimmed and lower-cased, duplicates collapse, an empty list means no restriction, and up to 500 addresses once de-duplicated, because the gate scans the list on every open. A visitor passes if they match the domains OR this list. Requires require_email: true, or the call returns a 422; an address that is not an address, or a list over the limit, returns a 422 too.
expires_in_hoursnumberneverPositive number. The link stops working after it.
slugstringgeneratedCustom link name. Paid plans.
domain_idstring | nullaccount defaultOmit it to use the account's own connected domain if one is live, or the HTMLRadar address if not. Pass null to force the HTMLRadar address on this one link. Pass a domain's id to use it explicitly — it must belong to this account and be live, or the call returns a 422.
request
curl https://htmlradar.com/api/v1/shares \
  -H "Authorization: Bearer hr_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "html": "<html>…</html>",
    "recipient_label": "Halbrook"
  }'
response — 201
{
  "share_id": "11111111-1111-4111-8111-111111111111",
  "document_id": "22222222-2222-4222-8222-222222222222",
  "url": "https://htmlradar.page/r/q3-proposal",
  "dashboard_url": "https://htmlradar.com/docs/22222222-2222-4222-8222-222222222222"
}

url — here and everywhere it appears below, including list-shares, revoke and activity — is read back from the link’s own row, never assumed from the request. On a custom domain it is that domain’s address; otherwise it is htmlradar.page. That is the exact address the recipient opens.

GET /api/v1/shares

The account’s links, newest first, fifty at a time. Pass ?before= with the previous page’s next_before to go back.

request
curl https://htmlradar.com/api/v1/shares \
  -H "Authorization: Bearer hr_live_…"
response — 200
{
  "shares": [
    {
      "share_id": "11111111-1111-4111-8111-111111111111",
      "slug": "q3-proposal",
      "url": "https://htmlradar.page/r/q3-proposal",
      "recipient_label": "Halbrook",
      "document_id": "22222222-2222-4222-8222-222222222222",
      "document_title": "Q3 proposal",
      "created_at": "2026-08-30T10:00:00.000Z",
      "revoked": false,
      "revoked_at": null,
      "expires_at": null,
      "expired": false,
      "opened": true,
      "last_open": "2026-08-31T09:00:00.000Z"
    }
  ],
  "next_before": 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.

request
curl https://htmlradar.com/api/v1/documents \
  -H "Authorization: Bearer hr_live_…"
response — 200
{
  "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.

request
curl https://htmlradar.com/api/v1/documents/22222222-2222-4222-8222-222222222222/html \
  -H "Authorization: Bearer hr_live_…"
response — 200
{
  "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.

request
curl "https://htmlradar.com/api/v1/shares/11111111-1111-4111-8111-111111111111/activity" \
  -H "Authorization: Bearer hr_live_…"
response — 200
{
  "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.

request
curl -O -J "https://htmlradar.com/api/v1/documents/22222222-2222-4222-8222-222222222222/reading-records" \
  -H "Authorization: Bearer hr_live_…"
response — 200, text/csv
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.

request
curl -X POST "https://htmlradar.com/api/v1/shares/11111111-1111-4111-8111-111111111111/revoke" \
  -H "Authorization: Bearer hr_live_…"
response — 200
{
  "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, PATCH /api/v1/shares/{id}

GET returns the link’s current settings in the shape PATCH takes them, with the lists in full, whether a password is set (never the password), and settings_version. Settings include allow_comments and the effective comments mode (off, private or shared). Any key can read it. Send that version back as expected_settings_version when replacing a whole list.

PATCH changes a link’s settings in place. The link, its address and its reading history stay the same. {id} is the share id, its slug or the link. Only the fields you send change: the rest keep the values they have, and null clears a field. A body that names no setting, or a field this route does not know, returns a 422.

FieldTypeMeaning
recipient_labelstring | nullWho the link is for. null clears it.
require_emailbooleanThe email gate.
verify_emailbooleanThe one-time code. Needs the email gate on, or the call returns a 422.
passwordstring | nullAt least 8 characters. null removes the password.
allow_commentsbooleanWhether readers can comment. The same permissions as the website apply. Omitted keeps the current choice.
lock_deckbooleanThe copy lock: blocks save and print and adds a watermark.
allowed_email_domainsstring[] | nullReplaces the whole list. Up to 500. null removes it. Needs the email gate on.
allowed_emailsstring[] | nullReplaces the whole list. Up to 500 once de-duplicated. null removes it. Needs the email gate on.
expires_atstring | nullA date and time such as 2026-12-31T17:00:00Z. null removes the end date. Not with expires_in_hours.
expires_in_hoursnumberThe link stops working this many hours from now.
show_sender"none" | "company" | "name"Who the gate names to the reader. Company needs a work sign-up address; name needs one set in Settings.
notify_first_openbooleanWhether you are emailed when someone first opens the link.
add_allowed_emails, remove_allowed_emailsstring[]Add to or remove from the stored address list on the server. The answer names what was added, removed, already there and not found. Not with allowed_emails. Removing the last entry of every list is refused.
add_allowed_email_domains, remove_allowed_email_domainsstring[]The same for the domain list. Not with allowed_email_domains.
expected_settings_versionintegerNot a setting: the settings_version GET showed. The write happens only if the link is still at that version, otherwise a 409.

The settings are checked as the link will end up, so turning the email gate off on a link that has an allow-list returns a 422 until the same call clears the list. The edit is applied under expected_settings_version when you send it, and under the version this call just read when you do not; if the link changed meanwhile the call returns a 409 and nothing is written. A company team’s rules apply exactly as on the website: a change that breaks one returns a 422 with the rule named. A change applies from the next time the page loads. It needs a full-access key at permission version 2 or later.

request
curl -X PATCH "https://htmlradar.com/api/v1/shares/q3-proposal" \
  -H "Authorization: Bearer hr_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "allowed_email_domains": ["halbrook.vc"], "password": null }'
response — 200
{
  "share_id": "11111111-1111-4111-8111-111111111111",
  "url": "https://htmlradar.page/r/q3-proposal",
  "settings_version": 5,
  "settings": {
    "recipient_label": "Halbrook",
    "require_email": true,
    "verify_email": false,
    "password_protected": false,
    "lock_deck": true,
    "allowed_email_domains": ["halbrook.vc"],
    "allowed_emails": null,
    "expires_at": null,
    "show_sender": "none",
    "notify_first_open": true
  }
}

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.

request
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…" }'
response — 201
{
  "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.

response — 200
{
  "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.

request
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" }'
response — 200
{
  "document_id": "22222222-2222-4222-8222-222222222222",
  "version": 2,
  "links_unchanged": true
}
request — edits
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:

StatuserrorWhen
401invalid_api_keyThe Authorization header is missing or the key is wrong.
402free_limit_reachedThe free plan’s two-link cap is reached; body has upgrade_url.
403read_only_keyA read-only key called a route that creates, changes, revokes or replaces.
403team_read_onlyThe key’s owner is on a company team whose administrator made keys and connections read-only. Only that administrator can change it.
403permission_update_requiredThe 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.
404not_foundWrong id, or someone else’s — both read the same, on purpose.
408request_timeoutThe request body stopped arriving before it finished.
409conflict/replace only: the document changed first (another replace, a delete, or an expected_version that is no longer current).
409share_changedPATCH 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.
413too_largeOver the 5 MB document cap. The body carries max_bytes.
422validationThe body is missing a required field or one is the wrong shape.
422team_ruleA rule set by the key owner’s company team (a required one-time code, company domains) refuses the change. The message names the rule.
429rate_limitedOver an hourly budget. retry_after_seconds is in the body and the Retry-After header.
500internal / storage_failedSomething broke on our side. Nothing was written.

Every limit is per hour, on a rolling window rather than the clock hour:

RouteBudget
GET /api/v1/me60/hour, per key
POST /api/v1/shares, POST /api/v1/documents/{id}/replace75/hour Pro, 30/hour free, per account — plus 120/hour per address
GET /api/v1/shares, GET /api/v1/documents, POST /api/v1/shares/{id}/revoke120/hour, per account
GET and PATCH /api/v1/shares/{id}, GET and POST /api/v1/documents/{id}/attachments, DELETE /api/v1/documents/{id}/attachments/{attachmentId}120/hour, per account
GET /api/v1/shares/{id}/activity300/hour, per key
GET /api/v1/documents/{id}/reading-records30/hour, per account
GET /api/v1/documents/{id}/html75/hour Pro, 30/hour free, per account — plus 120/hour per address
A missing or wrong key60/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.