support·proof

Documentation

How to put the agent on your site, how to give it something to answer from, and what it does when it does not know. Every figure on this page is read from the code that enforces it.

Install it

Two steps: paste one script tag into your site, then tell us which website addresses may use it.

<script src="https://supportproof.com/widget.js"
        data-key="pk_your_embed_key"
        data-api="https://supportproof.com"></script>

Both attributes are required and the script does nothing without them — it returns quietly rather than throwing, so a half-configured tag cannot take a page down with it.

Then add your website. A new account has no websites listed, which means the widget is refused everywhere. Open Install on your account and add your site's address — example.com is enough; anything after the name is dropped. Browsers call this the origin: the scheme and host, https://example.com. The same script tag then starts working. Until you do, every call returns 403 not authorized for this origin.

One level of wildcard is allowed — https://*.example.com — and http:// only for localhost, so you can develop against it. There is no bare *, ever: the embed key is public, and the origin is the only part of the pair a third-party site cannot forge from a browser.

The keys

They are not interchangeable. The embed key is public; the other two are secrets.

KeyWhere it livesWhat it can do
pk_… embed key Your page source, publicly readable Start a conversation, from an allowed origin only. On its own, nothing.
sk_… admin key Your server, never a browser Everything under /v1/admin: publish articles, read transcripts, approve refunds, change alerting.
rk_… read-only key A reporting tool or an AI assistant Only GET /v1/admin/summary/week, /summary/waiting, /summary/unanswered, /kb/docs and /outcomes/report. Every other admin request is refused with 403, including ones added later. No transcripts, and nothing a customer wrote.

/summary/unanswered returns how many questions your assistant could not answer, over the last 30 days and the last 7. It returns counts only, never the questions: a question can hold a name or an address. Read the questions themselves on the Drop-offs page, signed in.

We cannot show you your admin key again. Only a SHA-256 hash of it is stored. A product that can reveal your secret to you can reveal it to whoever gets into your session, so losing it means minting a new one from Install — which revokes the old one immediately, with no grace period. A key that keeps working for an hour after you revoke it is not revoked.

For a browser-based console, exchange the key for a short-lived token instead of shipping the key to the page:

curl -X POST https://supportproof.com/v1/admin/dashboard/token \
  -H "Authorization: Bearer sk_your_admin_key"

That endpoint refuses to run on a dashboard token, so a token cannot renew itself indefinitely — otherwise a token stolen once would be permanent, which is the property the expiry exists to remove.

Script tag attributes

AttributeRequiredWhat it does
data-keyyes Your pk_… embed key.
data-apiyes Where this service is served from. Trailing slashes are trimmed.
data-userno A label only. It is stored against the conversation and authorises nothing — it comes from an HTML attribute, which is to say from whatever the visitor's browser says. Never scope anything to it.
data-principal-endpointno A same-origin path that mints a signed identity assertion. This is the one that authorises. See Signed-in visitors.
data-localeno Defaults to the page's <html lang>.

There is deliberately no data-principal-token attribute. A token in markup goes stale on any page left open, is cached by whatever caches the HTML, and is readable by anything that reads the DOM.

What the agent will and will not say

Languages. The agent replies in the language the customer writes in, translating your help articles faithfully rather than adding to them, and keeping names, prices and order numbers exactly as written. For a first message too short to tell, it uses your page's language (data-locale, or the page's lang). An answer translated from an article is still labelled by that article.

Every reply carries one of four labels, and the label is stored with the message rather than computed for a chart later.

LabelMeaning
grounded A document supported it, above 0.45 cosine similarity. The citation names the article and the revision.
profile No help article supported it, but your business profile did: the question matched a filled-in field at the same threshold an article must reach, and the reply uses words or figures from that field. Counted as answered, never as grounded, left out of the "answered without a source" rate on both sides, and shown as "Answered from your business profile".
partial Something relevant was found, between 0.35 and 0.45. Said with less confidence, on purpose.
unsupported Nothing in the knowledge base or the business profile stood behind it. This is the number your dashboard shows you, and the one your public trust page shows everyone, if you publish one.

The two thresholds are configurable per deployment (CONFIDENCE_GROUNDED_AT, CONFIDENCE_PARTIAL_AT). The defaults are measured, and they are a mediocre operating point — fitted against one corpus with one embedding model, where the best achievable was modest coverage at a low confidently-wrong rate. They are the least-bad point we measured, not a good one, and you should re-fit them against your own content rather than trust ours.

Knowledge base and the publish gate

Publish an article with your admin key:

curl -X POST https://supportproof.com/v1/admin/kb/docs \
  -H "Authorization: Bearer sk_your_admin_key" \
  -H "content-type: application/json" \
  -d '{"title":"Refund policy","body":"…","url":"https://example.com/refunds"}'

Editing an article re-runs the questions it used to answer. The change is applied inside a transaction, past answers that cited the document are replayed against the new text, and the transaction is rolled back if an answer that used to be grounded stops being grounded. You get 409 naming the specific answers that would have got worse:

{ "blocked": true, "checked": 12,
  "regressions": [ … ],
  "hint": "Fix the article, adjust the affected regression cases, or re-send with ?force=true" }

A policy genuinely changes sometimes, so ?force=true skips the gate. It is recorded in the audit log with who did it — a gate with no override is a gate people route around, and an override nobody can see is worse than no gate.

Drafting articles from your website

An owner or admin can ask us to read the account's own website and draft help articles from it, on the signed-in Draft help articles from your website page. The address must already be one of the account's websites on the Install page.

  • At most 25 pages are read: the home page, pages it links to on the same website, and pages listed in /sitemap.xml. When there are more than that, pages whose address names support, help, contact, pricing, returns, privacy, terms or similar are read first. Links to other websites, and links with a query string, are not followed.
  • Only HTML pages up to 1 MB are read, each request stops after 10 seconds, and a website can be read once every 60 minutes.
  • Addresses on private networks, loopback and link-local ranges (including cloud metadata addresses) are refused, checked on the address actually connected to. Redirects are followed within the same website, and to one other address: the same one with www. added or removed, on the same scheme and port (so https://shop.com may send us to https://www.shop.com). Every other redirect is not followed, and the page tells you where it pointed.
  • Page text is used to write drafts and is not stored. A draft that mentions a figure, email address or web address the page does not contain is discarded.

Nothing is published by reading. Every draft waits for a person, who can edit it, add it or discard it. Adding one goes through the publish gate above, like any other article.

The free website check

The public website check uses the same reader, under tighter limits, for any address somebody types: at most 8 pages, each website at most 2 times a day whoever asks, and the same address and robots.txt rules. Its report is deleted after 7 days, or at once when someone with the report's link presses Delete this report.

For website operators

Requests identify themselves as:

SupportProofBot/1.0 (+https://supportproof.com/docs#website-drafts)

They follow robots.txt. To keep them off all or part of a website, name the SupportProofBot token:

User-agent: SupportProofBot
Disallow: /

Signed-in visitors

By default every visitor is anonymous and the agent can only read your public knowledge base. To let it act for a specific person — read their orders, move their appointment — your own backend signs a short-lived assertion and we check the signature.

<script src="https://supportproof.com/widget.js"
        data-key="pk_your_embed_key"
        data-api="https://supportproof.com"
        data-principal-endpoint="/api/support-token"></script>

That path must be same-origin. A cross-origin endpoint could not carry your login cookie anyway, so it cannot do the job; it can only send your visitors' browsers somewhere you did not intend. If your app already holds a token in memory, set window.supportAgentPrincipal to a function returning it and skip the round trip.

What to sign

A JWT, EdDSA over Ed25519. Exactly one algorithm is accepted; supporting two is how every algorithm-confusion bug in this format began.

{ "alg": "EdDSA" }
{ "sub": "your-own-user-id", "name": "Priya R.", "exp": 1893456000 }
ClaimRule
sub Required. Your id for the person, opaque to us, at most 200 characters. A longer one is refused rather than truncated — truncating would map two people onto one.
exp Required, and at most 24 hours ahead. An assertion with no expiry could never be revoked; a year-long one turns a session credential into a permanent one.
name Optional, for addressing them. Never used to decide what they may see.

We store only your public key. An HMAC secret would be easier for you to sign with and would mean our database held a credential capable of impersonating any of your users. The half that can forge stays on your servers.

An expired or unreadable assertion produces an anonymous session, not a failed page load — and is recorded, because a run of them is a broken integration rather than an attack.

Handover to a person

When the agent cannot answer, it hands over rather than assembling something confident out of adjacent paragraphs. From that moment the customer's messages go to your queue and the agent stops replying — a bot that keeps talking after promising a person is the single most complained-about behaviour in support automation.

Work the queue with your admin key: GET /v1/admin/handoffs, POST /v1/admin/handoffs/claim, POST /v1/admin/handoffs/:id/say, POST /v1/admin/handoffs/:id/resolve. The dashboard does the same thing with a mouse.

POST /v1/admin/handoffs/:id/suggestion returns a suggested reply drafted from the conversation and your own help articles, as { "draft": true, "sent": false, "suggestion": "…" }. It sends nothing: only say reaches the customer. It works while the customer is still waiting or being answered, and an account can ask for 20 an hour.

Allowances, and what happens at the cap

A message is one reply from the agent to a customer. A person reading your help centre costs nothing, and a handover to your team costs nothing. Voice is billed per reply, not per minute.

The cap is checked at the door, not per turn. A conversation that has started is allowed to finish; a new one is refused with 402 and a reason. Cutting somebody off mid-sentence to save a fraction of a cent is not worth doing.

The trial is 200 messages with no card. It stops and says so. There is nothing to charge on day fifteen because there is nothing on file.

Conversations are also capped per session by MAX_TURNS_PER_SESSION, which returns 429.

The trust page

A public page at /trust/<your-slug> showing what your agent actually did over the last 30 days, including the parts that went badly. Opt in from your billing page; revoke any time, and it stops being public immediately.

  • Counts, rates and dates only. Never a customer's words, never a question, never a contact detail. The type the page is built from has nowhere for free text to live, and a test plants a distinctive customer sentence and asserts it cannot reach the page.
  • A rate needs 30 behind it. Below that the page publishes the count and says why. One escalation out of three is three conversations, not 33%.
  • It is stamped. The stamp proves those exact figures for that period came from this system, so an edited screenshot will not match. It does not prove the underlying records are complete — we compute it from our own data with our own key, and the page says so in those words.
  • It puts you in the Verified Index. Publishing is what makes your figures comparable to everyone else who publishes. Nothing else opts you in, and revoking takes you out of the next read.

How you compare

Once 5 accounts running the same kind of agent at a similar volume have published, your page carries where you sit among them — computed with your account excluded from its own cohort, because otherwise a small group is largely measuring you.

The index never publishes a best or a worst figure, only the median and the middle half. A minimum or a maximum is one account's own number, and in a small cohort everybody can work out whose.

It is self-selected: only accounts that chose to publish are in it, so it reads better than the industry it is drawn from. That is stated on the index itself, and the type a cohort is returned in will not compile without carrying the admission.

How visitors use your site

Off until an owner or admin turns it on, per account, under Settings → Understand how visitors use your site. When it is on, the chat you already installed also counts how each page is used. Nothing else to install.

What is counted, for each page view

  • The page: its address without anything after a ? or #. Addresses containing @ are not counted. Long codes in addresses (like reset links) are replaced with :token, and numbers with :id unless you turn that off.
  • How long the page was actually on screen — not while the tab was in the background — up to 30 minutes.
  • How far down it was scrolled, as 25, 50, 75 or 100%.
  • Where clicks landed, as a square on a grid 20 columns wide and 100px tall, and whether the screen was a phone, tablet or computer.
  • Which links and buttons were clicked: the kind of element, its role, and for links and buttons only, their words cut to 40 characters. Three quick clicks on the same thing are counted as one "rage click", as a number.
  • Where the view came from: another page on your site, the name of another website (never its full address), or nowhere.
  • Whether the page view converted, and whether a chat was started on it.

Each view is added into daily totals as it arrives and then forgotten.

How long the totals are kept

  • Daily totals are kept for 13 months, then deleted automatically, for every account.
  • If an owner or admin turns counting off, collection stops at once and everything counted is deleted 30 days later. Turning it back on before then cancels that.
  • An owner or admin can delete every total for the account straight away under Settings → Delete all visitor totals now. It asks you to confirm first, and cannot be undone.

What is never collected

No cookies, and nothing written to the visitor's browser (no local storage, session storage or IndexedDB). No visitor or device identifier and no fingerprinting. No IP address stored. No screen recording. Never what is typed into a form, the text of anything inside a form other than its submit button, editable text, or passwords. Nothing at all is sent when the visitor's browser has Do Not Track or Global Privacy Control turned on.

Keeping part of a page out

Add data-sp-private (or class="sp-private") to any element. Clicks on it, or on anything inside it, are not counted at all — not even their position.

<section data-sp-private>
  <p>Your balance: …</p>
</section>

If your website must ask visitors before measuring how they use it, turn on Only count visitors who have agreed to analytics in settings, or add data-insights-consent="required" to the script tag. Either one is enough. Then nothing is measured, nothing is sent, and the counting code is not even downloaded on a page until that page tells the chat the visitor said yes:

<script src="https://supportproof.com/widget.js"
        data-key="pk_your_embed_key"
        data-api="https://supportproof.com"
        data-insights-consent="required"></script>

<script>
  // When the visitor accepts analytics in your consent banner:
  window.supportProof?.allowInsights();

  // When they decline, or later withdraw:
  window.supportProof?.denyInsights();
</script>

Most cookie and consent banners let you run a line of your own code when a visitor accepts or declines a category, often called "analytics" or "statistics". Put the calls there. Three things to know:

  • Call it on every page. We do not remember the answer from one page to the next, because remembering it would mean storing something in the visitor's browser. If your banner already knows the visitor agreed, call allowInsights() as the page loads.
  • Put the chat's script tag above your banner's code if the banner can answer straight away, so window.supportProof exists when it does. A call made before the chat's script has run does nothing.
  • denyInsights() works whether or not you ask first: it stops counting for that page view and throws away what was counted on it. Nothing from before the visitor agreed is ever counted.

Do Not Track and Global Privacy Control still win: a browser sending either is not counted, whatever the page says.

Telling us about a conversion

Either list the pages people reach after buying or booking, like /thank-you, in settings, or call this from your own code when something you care about happens:

window.supportProof?.conversion("booking");   // a name is optional
window.supportProof?.conversion();

A name is lowercase letters, numbers, spaces, dots, dashes or underscores, up to 40 characters. It is counted once per page view however many times it is called, and nothing else is sent with it. When counting is off the call does nothing.

Limits, plainly

  • Each page view is reported once, when the page is first hidden or left. If a visitor switches tabs and comes back, what they do after coming back is not counted.
  • A single-page app that changes pages without reloading counts as one page view.
  • A conversion counts as chat-assisted only when the chat was used on the same page view. We can only connect a chat to a conversion in the same visit to that page; we do not follow visitors.
  • Visitors with Do Not Track or Global Privacy Control, and pages where the chat does not load, are not in these numbers.
  • Charts and lists appear once the account has 50 page views in the period; until then the page shows the count so far. A page's click heatmap, busiest spots and clicked links appear once that page has 100 views in the period. With fewer, a dark square can be one person's clicks.

Tell your visitors on your own privacy page. Settings has a paragraph you can start from; have your lawyer review it.

Exporting and deleting conversations

Your conversations are yours to take out and to remove, with your admin key and no request to us.

Export

curl -H "Authorization: Bearer sk_…" \
  "https://supportproof.com/v1/admin/conversations/export?from=2026-01-01&to=2026-03-31"

JSON Lines: one conversation per line, with every message (tool calls included), the citations behind each answer, the outcome, and any handover with what your people wrote. Add format=csv for one row per message. Up to 500 conversations a page (limit); when there are more, the response carries an x-next-cursor header, which you pass back as after.

Delete one conversation

curl -X DELETE -H "Authorization: Bearer sk_…" \
  "https://supportproof.com/v1/admin/conversations/<conversation id>"

Removes the conversation and every copy of its words: messages, citations, outcome, handovers and what was typed in them, replay cases built from it, and its samples in knowledge-base proposals. Tickets, refund requests, leads and appointments it created are kept, detached from it, unless you add ?records=true, because a refund someone is owed should not disappear with the chat that raised it.

Delete everything before a date

curl -X POST -H "Authorization: Bearer sk_…" \
  -H "content-type: application/json" \
  -d '{"before":"2026-01-01","confirm":"delete"}' \
  "https://supportproof.com/v1/admin/conversations/delete"

Up to 1000 conversations a call; repeat while the response says "more": true. Run it on a schedule and that is your retention period. It needs the admin key itself, not a dashboard token.

Deletion is permanent and cannot be undone, and it is recorded in your audit log without any of the content. Your trust page and the Verified Index recount from what remains. Billed usage does not change, but a deleted conversation can no longer be used to check an invoice line.

API reference

Widget API — x-embed-key header, from an allowed origin

EndpointWhat it does
POST /v1/session Handshake. Returns a signed session token, your branding and capabilities, and whether the visitor's identity is none, expected or verified. An optional page is the address the chat was opened on; only its origin and path are kept, and only when the origin is the one the request came from.
POST /v1/message One turn. Returns the reply, its citations, its confidence label, and whether it has escalated.
POST /v1/handoff/poll Anything a person has said back.
POST /v1/insights/visit One page view, sent once by the widget when counting visits is on. The embed key travels in the text/plain body instead of a header, because a beacon cannot set one; the Origin is checked the same way. Answers 204 when counted, 403 when counting is off.

A caller with no Origin header — curl, a server — is refused. Server-to-server work belongs on the admin API with a real secret, not on a key that sits in a public page.

Admin API — Authorization: Bearer sk_…

Every endpoint is scoped by the key itself. There is no tenant parameter anywhere in it, on purpose: an admin endpoint that takes the account as an argument is one missing check away from being cross-account.

AreaEndpoints
Knowledge POST|GET /v1/admin/kb/docs, GET /v1/admin/provenance/message/:id, GET /v1/admin/provenance/doc/:id/stale
Queue GET /v1/admin/handoffs and the claim / say / transcript / resolve routes beneath it
Your data GET /v1/admin/conversations/export, DELETE /v1/admin/conversations/:id, POST /v1/admin/conversations/deletehow
Outcomes POST /v1/admin/outcomes/classify, GET /v1/admin/outcomes/report
Alerting GET|POST /v1/admin/alerts…, thresholds, destinations, suppressions, and GET /v1/admin/alerts/history.csv
Usage GET /v1/admin/usage, GET /v1/admin/usage/health
Refunds GET /v1/admin/refunds, POST /v1/admin/refunds/:reference/decide

The agent files refund requests. It never moves money; a person decides, and the decision is audited.

Choosing what the agent is for

A role is a persona, a tool list, and — the part that matters for your numbers — a definition of what counts as the job being done. Change it on your agent.

RoleNeedsCounts as done when
support
Customer support
kb resolved
lead-capture
Lead capture and qualification
leads captured, disqualified
scheduler
Appointment scheduling
scheduling attended, booked
shopper
Personal shopper
catalogue handed_off, nothing_suitable
sales
Sales associate
catalogue, leads handed_to_human, captured, booked, no_fit
assistant
Personal assistant
memory acted, declined_to_act

The definition of success inverts between roles, and that is the point. A support conversation where the customer goes quiet is abandoned — silence is not consent, so resolved needs confirming. A lead-capture conversation where they go quiet already has the details written down, so captured needs no confirmation at all. Getting that backwards produces a number that flatters you, which is worse than no number because it gets acted on.

Three things the form will refuse, rather than save and let you find out from a customer:

  • A role without the tool it is named after. A scheduler that cannot schedule still starts, still answers, and is quietly the wrong product. The failure has no symptom until somebody hits it.
  • Voice on a plan that does not include it. And it is checked again on every conversation, so a downgrade takes it away without anybody having to remember.
  • A personal assistant with no key to verify visitors. It acts for a specific signed-in person; with no key every visitor is anonymous, so it would talk as though it knew them. See Signed-in visitors.

Roles a role collects contact details for are ticked explicitly, not inherited from a dropdown: storing your visitors' names and phone numbers is your decision to make, not a side effect of choosing a persona. Conversations already open keep the agent they started with.

What is not built yet

Stated here rather than discovered by you.

  • The roles are a fixed list. Each one carries its own definition of success, so a persona nobody has defined an outcome for cannot be measured honestly — which is why there is no free-text persona box. A role you need and cannot see is worth asking for.
  • The key that verifies signed-in visitors is still set by us. Pasting a public key is safe, but getting it wrong means every assertion silently fails to verify, so it has not been opened up yet. Ask, and it takes a minute.
  • Voice depends on the deployment. The plan table says which plans include it; whether a given installation has speech configured is separate.
  • The chat's own buttons are in English. Replies follow the customer's language, but the widget's buttons, the receipt page and the trust page are English for now.
  • There is no bulk import for knowledge. Articles go in one call at a time, or through the catalogue feed if you sell products.