Embedded support ticketing
"It's broken." No URL. No screenshot. No account. No browser.
So somebody on your team spends the next two days asking four questions before anyone can start fixing anything. Beacon captures all four answers the second they hit send.
Acme Ltd · signed in as Alice Chen
Your page. Your session. Our panel, in a shadow root — the only thing it shares with the page around it is a rectangle.
The part nobody budgets for
Four emails. Two days of calendar time. Then work starts.
That is not a support problem. It is a data-collection problem, and you are solving it with people — the most expensive and least reliable way to collect four facts a browser already knows.
What arrives instead
Not a form your customer has to fill in properly. Captured, at the moment they hit send, whether they thought of it or not.
data-support-redact and it is blacked out on
the canvas before your customer reviews it. The unredacted image
never leaves their browser.
What it takes to add
Everything else is a settings screen.
Launcher shape, which tabs exist, your own CSS, which URLs it appears on, who gets emailed — all of it is server-side. Changing any of it never touches your codebase a second time, and never needs your deploy window.
Authenticated by your own session. Returns a signed, 120-second, single-use assertion. About twenty lines. This is the only code you write.
47 KB gzipped and unminified, zero dependencies, and everything past the launcher loads lazily. It does not block your render.
Worked examples
Six ways it actually gets wired up.
Every one of these is the shipped API, not an illustration of one. The
options are the options boot() takes; the events are the events
it dispatches; there is nothing here you will discover we do not have.
Three options, and the third is a call into the endpoint you built. Launcher shape, which tabs exist, theme, which URLs it appears on — none of that is here, because none of it is yours to redeploy for.
<!-- the entire client-side integration --> <script type="module"> import { boot } from "https://cdn.beacon.example/widget/v1/loader.js"; boot({ baseUrl: "https://support.example.com/api/v1/widget", projectId: "prj_4f9c2a…", // your backend vouches; this page never sees the signing secret getAssertion: async () => { const r = await fetch("/portal/support-identity", { method: "POST" }); return (await r.json()).assertion; }, }); </script>
The URL is versioned and ours. You do not vendor a copy into your web root — a copy is a thing that gets left behind.
The one most integrations actually want. beacon:unread fires
whenever the count changes — including while the panel has never been
opened, because the widget polls from the moment it boots. Your customer
sees the reply in your own navigation, where they already look.
<span id="nav-support-badge" hidden></span> <script type="module"> import { boot } from "https://cdn.beacon.example/widget/v1/loader.js"; const supportOptions = { /* baseUrl, projectId, getAssertion — see Minimal */ }; const badge = document.querySelector("#nav-support-badge"); // Our events bubble and are composed, so `document` is a fine place to // listen — and listening before boot() means you cannot miss a count. document.addEventListener("beacon:unread", (event) => { const n = event.detail.unread; badge.textContent = n ? String(n) : ""; badge.hidden = !n; }); boot(supportOptions); </script>
Only { unread } is in the detail. The count, and nothing about the tickets behind it.
Switch our launcher off in the project settings — launcher_mode:
hidden, a server-side setting, not an option on this page — and open
the widget from a control that looks like the rest of your product.
boot() resolves to the control surface, or to null
when the project is inactive or a URL rule excludes this page. So you can
decide whether your button should exist at all.
<button id="contact-support" type="button" hidden>Contact support</button> <script type="module"> import { boot } from "https://cdn.beacon.example/widget/v1/loader.js"; const supportOptions = { /* baseUrl, projectId, getAssertion — see Minimal */ }; const button = document.querySelector("#contact-support"); const beacon = await boot(supportOptions); if (beacon) { button.hidden = false; button.setAttribute("aria-expanded", "false"); button.addEventListener("click", () => beacon.openNewRequest()); // Reflect our state in your own chrome. beacon.isOpen() answers the // same question on demand, if you would rather ask than listen. beacon.on("open", () => button.setAttribute("aria-expanded", "true")); beacon.on("close", () => button.setAttribute("aria-expanded", "false")); } </script>
The same object is published as window.Beacon, for a button
rendered somewhere your bootstrap cannot reach. Pass
expose: false and nothing is published; pass a name and it is
yours. We never overwrite one you are already using.
Boot once, from the shell that outlives your routes — an effect with an
empty dependency list, onMounted, whatever your framework calls
it. Booting from a route component gives you one widget per navigation and
a global that gets clobbered.
// support.js — imported by your app shell, never by a route component. import { boot } from "https://cdn.beacon.example/widget/v1/loader.js"; const supportOptions = { /* baseUrl, projectId, getAssertion — see Minimal */ }; let widget = null; export async function mountSupport() { if (widget) return widget; // once, not once per route widget = await boot(supportOptions); return widget; } export function unmountSupport() { // Removes our element, detaches every handler registered through // on() or hooks:, and releases the window.Beacon name. widget?.destroy(); widget = null; }
The project's allow and deny patterns are checked against
location immediately after the config fetch, and never again.
A pushState route change does not re-evaluate
them: a widget that booted on an allowed URL stays, and one that
declined to appear stays away.
If your rules have to follow the route, call destroy() and
boot() again on navigation — and accept one config request
per route for the privilege.
Every beacon:* event through one handler. The payloads are
allowlisted projections, not the internal record — there is no priority, no
assignee and no SLA field in any of them, so what you forward is bounded by
what your customer could already see.
<script type="module"> import { boot } from "https://cdn.beacon.example/widget/v1/loader.js"; const supportOptions = { /* baseUrl, projectId, getAssertion — see Minimal */ }; const beacon = await boot(supportOptions); if (beacon) { const E = beacon.events; // the published vocabulary; don't hard-code const detach = [ beacon.on(E.OPEN, () => analytics.track("support_opened")), beacon.on(E.CLOSE, () => analytics.track("support_closed")), beacon.on(E.VIEW, ({ view }) => analytics.track("support_view", { view })), // A title is your customer's own prose. Forward the identifiers. beacon.on(E.TICKET_CREATED, ({ id, issue_type }) => analytics.track("support_ticket_created", { id, issue_type })), beacon.on(E.MESSAGE_SENT, ({ ticket_id }) => analytics.track("support_reply_sent", { ticket_id })), beacon.on(E.TICKET_TRANSITIONED, ({ ticket_id, action }) => analytics.track(`support_${action}`, { ticket_id })), // { code, message } — the customer-safe pair, nothing internal. beacon.on(E.ERROR, ({ code }) => analytics.track("support_error", { code })), ]; // Each on() hands back its own detach function. addEventListener("pagehide", () => detach.forEach((off) => off())); } </script>
It is emitted at the end of boot(), so a handler attached to
the returned handle has already missed it. That is what the
hooks option exists for — it attaches before anything can
fire: boot({ …supportOptions, hooks: { ready: onReady } }).
A listener on document registered before the call works too.
Hooks observe; they do not intercept. The events are
cancelable: false and no handler can stop a customer raising
a request. Your handler also never runs inside our stack — if it throws,
the platform reports it against your function and our dispatch returns
normally.
metadata travels with every submission and is shown to your
support staff beside the ticket. It is internal-visibility only: your
customer never sees it, and it never appears in an event payload.
<script type="module"> import { boot } from "https://cdn.beacon.example/widget/v1/loader.js"; boot({ baseUrl: "https://support.example.com/api/v1/widget", projectId: "prj_4f9c2a…", getAssertion: mintAssertion, // as in Minimal // What triage wants to know before anyone opens the ticket. metadata: { plan: "enterprise", seats: 240, account_age_days: 612, flags: ["new-billing", "sso"], portal_release: "portal@2026.8.3", }, }); </script>
It is stored indefinitely and cannot be deleted. Nothing in this system has a retention expiry, so whatever you put here is in the record permanently and a data-subject erasure request cannot reach it. It is also the one uncontrolled input we take: nothing validates what your page writes.
No session identifiers. No tokens or API keys. No special-category personal data. Plan tier, account age, feature flags, your own release number — that is what it is for.
It is the object you passed, read when a request is submitted rather than copied at boot. Updating a field on it later is picked up; swapping your variable for a different object is not.
Before you put it in front of your customers
You are about to run somebody else's software inside your product, in front of your customers, with your name above it. These are fair questions and they get straight answers.
It renders in a shadow root with all: initial. Your CSS
cannot reach it and its styles cannot escape. A global
* { box-sizing: content-box } on your page is a non-event.
Zero dependencies — no framework, no polyfills, no analytics of ours riding along inside your product. Everything past the launcher is lazy.
If our API is unreachable the widget removes itself and says nothing. No thrown error, no console output, no layout shift.
The silence is deliberate: noise from an unfamiliar vendor is indistinguishable from your page breaking.
We never accept an identity from browser JavaScript — only a signed assertion your backend mints, valid 120 seconds, single-use.
Support history is visible across an organisation, so anything weaker would be a way to read all of it.
Customers never see SLA data. Not a timer, not a badge, not an apology for lateness — the customer serialiser has no field able to carry one, and every notification template is scanned for the vocabulary in tests.
Every appearance and behaviour setting is per project: launcher, tabs, your own CSS, theme, which URLs it appears on, who gets notified. One deployment, any number of tenants, none of them touching your code.
How it was built
Every requirement numbered. Every acceptance criterion testable. Every decision written down with its reasoning — including the ones that were later reversed, and who reversed them.
The bad news, up front
You will find these in due diligence. Finding them yourself is worse than hearing them now, so here they are.
No retention expiry, no purge, no cascade delete. That is what makes the audit log trustworthy, and it also means a data-subject erasure request cannot be satisfied today. It is a deliberate trade and it needs your legal team's sign-off before you deploy, not after.
Theme colours are contrast-checked when you save them. An arbitrary stylesheet cannot be — no save-time filter can prove one stays legible in every state. So a project on custom CSS carries no WCAG AA conformance from us, and the system says so at the moment you save it.
There is no load test, no restore drill and no third-party accessibility audit. The queries are indexed and free of N+1 and there are tests proving it — but nobody has yet put this under real traffic, and we are not going to quote you a figure we have not measured.
See for yourself
Each one a real project with its own signing secret. Open two of them and note that the page around the widget never changes — and that on Globex the widget is genuinely absent from checkout rather than hidden there.