Embed the hotline.red feedback widget on your site, or call the public ingest API directly. This guide covers both, the gotchas that actually bite during integration, and copy-paste prompts for wiring it up with an AI coding agent.
hotline.red is an embeddable feedback / bug-report widget. Your site loads it with a public key (pk_…, safe to ship to the browser) and it talks to the hotline API cross-origin. Two ways in:
<script> tag. Best for almost everyone./v1/* endpoints yourself. For custom UIs, native webviews, or server-side filing.These are dashboard steps. No amount of code substitutes for them — the #1 "works locally, 403s in prod" bug is a missing origin.
pk_…). The public key is not a secret — it is meant to ship in the browser. The instance also has a secret — never put that in client code.https://app.example.com, https://example.com, and http://localhost:8000 for local dev. Matching is exact (scheme + host + non-default port): https://example.com does not cover https://www.example.com or http://example.com. There is no subdomain wildcard (a literal * entry means "any origin" and is for testing only).Add one tag. The bundle renders its own launcher + panel in a shadow DOM; you build no UI.
<script
type="module"
src="https://cdn.hotline.red/hotline.js"
data-public-key="pk_your_key_here"
data-api-base="https://api.hotline.red"
async
></script>
type="module"is required. The bundle is an ES module — its first bytes are a bareimport. A plain<script>tag silently never executes it, so you get no widget and no error. This is the single most common broken integration.
It auto-boots when it sees data-public-key (or a window.__HOTLINE__ = { publicKey, apiBase } global set before load). See integration-csp.md for all three embed modes (script / floating / bookmarklet) and the required Content-Security-Policy.
Two variants of the same tag, for two different problems.
A tag manager can only inject a classic script, and a classic <script> cannot
execute an ES module — so pasting the Option A snippet into Google Tag Manager
produces no widget and no error at all. Use the loader instead. It is a ~1KB
classic script whose only job is to inject the module tag correctly:
<script
src="https://cdn.hotline.red/hotline-loader.js"
data-public-key="pk_your_key_here"
data-api-base="https://api.hotline.red"
async
></script>
The loader also fetches https://cdn.hotline.red/c/<publicKey>.js first — a
classic script carrying this instance's config. Classic scripts are not
CORS-gated: the browser sends no Origin, performs no preflight, and the response
needs no Access-Control-Allow-Origin. So the widget boots without a single
cross-origin config request, and the entire "blocked by CORS policy" class of
failure cannot occur on the config path.
If a config script is missing or slow the loader boots the widget anyway after 2 seconds, falling back to fetching config normally. Config delivery can never take the widget down.
The shortest form of all is one classic tag with no attributes at all — the config script loads the bundle itself:
<script src="https://cdn.hotline.red/c/pk_your_key_here.js?loader=1&cdnBase=https%3A%2F%2Fcdn.hotline.red"></script>
The tradeoff is a serialized round trip: the bundle cannot begin downloading until this script has executed. The two-tag form below lets them load in parallel.
To do it without the loader — two tags, in this order, neither async so document
order is execution order:
<script src="https://cdn.hotline.red/c/pk_your_key_here.js"></script>
<script
type="module"
src="https://cdn.hotline.red/hotline.js"
data-public-key="pk_your_key_here"
data-api-base="https://api.hotline.red"
></script>
Config edits reach browsers within about a minute (a 60s CDN cache plus the ~30s server cache). Disabling an instance takes the same.
If you already run a reverse proxy, this is the only configuration with no CORS whatsoever — not merely no preflight. Proxy a path on your own domain to the hotline API, then point the widget at that path:
location /hotline/ {
proxy_pass https://api.hotline.red/;
proxy_set_header Host api.hotline.red;
}
<script
type="module"
src="/vendor/hotline/hotline.js"
data-public-key="pk_your_key_here"
data-api-base="/hotline"
></script>
Every request is now same-origin, so the browser sends no Origin header, runs no
preflight, and applies no allow-origin check. Your CSP needs nothing
vendor-specific: script-src 'self'; connect-src 'self'.
Two things you must know before choosing this:
Origin
header there is nothing to check, so your proxy becomes the boundary. That is
reasonable — it is your infrastructure — but it is a real change in who enforces
access, not a detail./socket.io/ too if you use the voice or text agent. The widget mounts
the live channel under your prefix (/hotline/socket.io), so a proxy that only
forwards /hotline/v1/* will connect for feedback and silently fail for voice.Self-host the bundle alongside it (copy the whole dist/ — the chunks resolve
relative to the entry) or keep loading it from the CDN; only data-api-base
decides where requests go.
The anonymous ingest surface. Every browser call is cross-origin, so the Origin allowlist above applies to all of them.
| Method & path | Purpose | Auth |
|---|---|---|
GET /v1/config/:publicKey |
Browser-safe widget config (branding, feature flags) | public key in path |
POST /v1/feedback |
Create a feedback ticket | public key in body |
POST /v1/uploads/ticket |
Get a short-lived upload ticket | public key in body |
PUT /v1/uploads/blob?token=… |
Upload bytes | HMAC ticket (no key) |
GET /v1/uploads/blob?token=… |
Read bytes back | HMAC ticket (no key) |
Rate limits (bucketed per public key + client IP): config 60 / min, feedback 30 / min.
const res = await fetch("https://api.hotline.red/v1/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
publicKey: "pk_your_key_here", // ALWAYS in the body (see Fundamentals)
channel: "text",
message: "The checkout button does nothing",
page: { url: location.href, title: document.title },
emailOptIn: false,
}),
});
// 201 -> { id, status, createdAt }
Order matters. Submit the feedback first, then request the ticket, then upload — the feedback's returned id is what links the attachment. Pass that id as the upload ticket's clientSessionId; the backend links the stored blob to that session when you upload it. A blob uploaded with no matching session is stored but not linked, and nothing re-links it later — so never upload before the feedback exists. The prebuilt widget handles this ordering for you, so prefer Option A if you need attachments.
Allowed kinds and content types: screenshot (image/png, image/jpeg, image/webp), audio (audio/webm|ogg|mpeg|mp4|wav), replay (application/json). Default max size 10 MiB.
// 1) submit feedback FIRST — its returned id is what links the attachment
const fb = await (await fetch("https://api.hotline.red/v1/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
publicKey: "pk_your_key_here",
channel: "text",
message: "The checkout button does nothing",
page: { url: location.href, title: document.title },
emailOptIn: false,
}),
})).json(); // 201 -> { id, status, createdAt }
// 2) request an upload ticket correlated to that feedback id
const t = await (await fetch("https://api.hotline.red/v1/uploads/ticket", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
publicKey: "pk_your_key_here",
kind: "screenshot",
contentType: "image/png",
sizeBytes: blob.size,
clientSessionId: fb.id, // the id returned by step 1 — this is the link
}),
})).json(); // 201 -> { token, uploadUrl, method, maxBytes, expiresAt, storagePath }
// 3) upload the bytes (uploadUrl already contains ?token=) — now linked to fb.id
await fetch(t.uploadUrl, { method: t.method, headers: { "Content-Type": "image/png" }, body: blob }); // 201
Don't paste prompts — connect the agent. The dashboard's AI integration (MCP) section gives you one connection string:
https://api.hotline.red/mcp/<token>
Added to your agent (one command — the dashboard shows it per harness), it exposes tools to read these docs, fetch exact embed code with your real key, diagnose and add allowed origins, inspect your deployed page, and run a live end-to-end test that proves the whole pipeline before anyone declares the integration done.
The full tool reference is in AI integration (MCP).
A browser cannot tell you why an embed failed. When the origin allowlist rejects a
request, the 403 carries no Access-Control-Allow-Origin, so fetch rejects
before a response object exists: an unknown key, a disabled instance, an origin
that isn't allowlisted, and a CSP block all surface as the same opaque
TypeError. The console shows only:
Access to fetch at 'https://api.hotline.red/v1/config/pk_...' from origin
'https://your.site' has been blocked by CORS policy: No 'Access-Control-Allow-Origin'
header is present on the requested resource.
That message is the same for all four causes. Ask the diagnose endpoint instead — it is deliberately not origin-gated, so it answers even from the page that is broken:
curl -s "https://api.hotline.red/v1/diagnose?publicKey=pk_your_key_here" \
-H "Origin: https://your.site"
{
"ok": false,
"reason": "origin_not_allowed",
"instance": { "name": "Acme staging", "status": "active", "captureLevel": 1 },
"origin": { "sent": "https://your.site", "normalized": "https://your.site", "allowed": false },
"checks": { "keyResolves": true, "instanceActive": true,
"originAllowed": false, "allowlistEmpty": false },
"remediation": "https://your.site is not on the allowed-origins list for \"Acme staging\" …"
}
reason |
What it means | Fix |
|---|---|---|
ok |
Key and origin are both fine | If there's still no widget, check the tag has type="module" |
unknown_key |
No instance has this key | Typo, or a key from another environment |
instance_disabled |
An operator switched it off | Re-enable it in the dashboard |
origin_not_allowed |
Key is fine, this origin isn't listed | Add the origin (see allowlistEmpty) |
no_origin_sent |
You called without an Origin header |
Re-run with -H "Origin: https://your.site" |
Check instance.name. It tells you which instance your key points at. If that
name isn't the one you expected, the site is embedding the wrong key — the fix is to
change the key, not the allowlist. allowlistEmpty: true means the instance blocks
every site, which looks identical to an outage from the browser.
The endpoint never returns the allowlist itself — only a verdict on the origin you sent — so it is safe to call from a public page. It is rate-limited to 30/min per key + IP.
http://localhost:<port> for dev.{ "publicKey": "pk_…" }) on POSTs. It's the one place every server layer reads consistently. Do not send it in a custom header from a browser: any non-safelisted header forces a preflight on every single request.text/plain), so there is no OPTIONS round trip to reason about. A preflight that DOES carry a resolvable key (?publicKey=, or /v1/config/:key) is now enforced against the allowlist and 403s a non-allowlisted origin; a keyless one still returns 204, and the actual request remains the boundary either way. Test with a real POST, or with /v1/diagnose.clientSessionId = the feedback's returned id, then upload — that's what links the file. A blob uploaded before its feedback exists is stored but not linked. Allowed kinds/types: screenshot (png/jpeg/webp), audio (webm/ogg/mpeg/mp4/wav), replay (json). Max 10 MiB.script-src / connect-src / img-src / worker-src directives.