Integrating hotline.red

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:

Before you write any code

These are dashboard steps. No amount of code substitutes for them — the #1 "works locally, 403s in prod" bug is a missing origin.

  1. Create a widget instance in the hotline dashboard and copy its public key (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.
  2. Add every origin your site is served from to the instance's Allowed Origins allowlist — e.g. 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).
  3. Allowlist / status / config changes take up to ~30 seconds to take effect (server-side cache). If you just added your origin and still get 403, wait 30s before debugging further.

Option A — Drop-in widget (script)

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 bare import. 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.

Option A2 — Tag manager, or config without CORS

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.

Option A3 — Same-origin path proxy (no CORS at all)

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:

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.

Option B — Direct API

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.

Submit feedback

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 }

Attach a screenshot / file

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

Integrate with an AI agent (MCP)

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).

The widget isn't appearing — ask the API why

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.

Fundamentals (the gotchas)

Related