For engineers putting Beacon into their own product
Embedding the support widget
For engineers putting Beacon into their own product.
There are two pieces of work and only one of them is on your page:
- A backend endpoint that vouches for the signed-in user. This is the whole security model and it is the part you cannot skip.
- A script tag.
Where the script comes from
Two hostnames, and they do different jobs (
OQ-066):
cdn.beacon.inits.dev— the loader and its modules. This is what goes in yourscript-src.api.beacon.inits.dev— where the widget sends requests. This is what goes in yourconnect-src, and what you pass asbaseUrl.They are separate so that a CDN can be put in front of the first without touching the second, and so that neither can move without the other staying put.
The major version is in the path and the path is what you pin. If a CDN is ever put in front of this, the hostname you write into your Content-Security-Policy is the only thing that changes — and you would be told before it did, because a CSP entry cannot be corrected in a patch release on your side.
The widget is an ES module served from Beacon. You do not vendor it into your web root, and there is no build step:
https://cdn.beacon.inits.dev/widget/v1/loader.js
The major version is in the path, and the path is what you pin. A released version never changes incompatibly: fixes and additions ship to /v1/ continuously and reach you on your users' next page load, and anything that would break an existing embed ships as /v2/ instead. So pin /v1/ and leave it — pinning any tighter than that means declining the fixes.
The loader is small and async, and it fetches the rest of the widget itself only once there is a reason to. Nothing past the launcher is on your critical path.
If your CSP forbids third-party script origins, you can host the file yourself. Fetch the versioned module, serve it from an origin you already allow, and point the import at that instead. Nothing else changes — the widget still talks to our API directly, so connect-src still needs our API origin. What it costs you is the update path: a self-hosted copy is frozen at the moment you fetched it, so re-fetching becomes something you own.
1. The identity handshake
Beacon never decides who someone is. Your backend does, and signs a short-lived assertion saying so.
Why it works this way. Support history is visible across an organisation: a customer sees their colleagues' tickets, because a support request is usually about the account rather than the person. So an identity Beacon accepted from browser JavaScript would let anyone who can open your portal read your entire organisation's support history by claiming to be someone else. The signature is what makes that impossible, and it is why the signing secret must never reach a page.
What you build
One endpoint, authenticated by your own session, that returns a signed assertion for the user currently signed in.
# Your portal's backend. Not ours.
import hashlib, time, uuid, jwt
# The secret is shown once when you rotate it in the Beacon admin. Store it the
# way you store any other credential; it is never sent to a browser.
KEY = hashlib.sha256(BEACON_SIGNING_SECRET.encode()).digest()
@app.post("/portal/support-identity")
def support_identity():
user = current_user() # your session, your rules
now = int(time.time())
return {"assertion": jwt.encode({
"iss": "your-portal",
"aud": "beacon",
"sub": user.id, # stable per user, opaque to us
"name": user.full_name,
"email": user.email,
"org_id": user.organisation_id, # who they share history with
"project_id": BEACON_PROJECT_ID,
"iat": now,
"exp": now + 120, # 120s is the maximum accepted
"jti": str(uuid.uuid4()), # single-use; a repeat is refused
}, key=KEY, algorithm="HS256")}
The four rules
| Rule | Why |
|---|---|
| Mint it server-side | A page that can mint can impersonate |
| 120 seconds maximum lifetime | It is exchanged for a session immediately; a long-lived assertion is a bearer token in your logs |
A fresh jti every time | Single-use. A replayed assertion is refused |
org_id decides who shares history | Two users with the same org_id see each other's tickets. Get this wrong and you have a data leak |
sub is opaque to us and should be stable: it is how a returning user is recognised. An email address works, but changes when someone changes theirs, and their history follows the sub.
2. The embed
<script type="module">
import { boot } from "https://cdn.beacon.inits.dev/widget/v1/loader.js";
boot({
baseUrl: "https://api.beacon.inits.dev/api/v1/widget",
projectId: "…",
getAssertion: async () => {
const res = await fetch("/portal/support-identity", { method: "POST" });
return (await res.json()).assertion;
},
});
</script>
That is the whole integration. Everything else on this page is optional.
boot() is async, and can resolve to null
It returns a handle you can drive — but three things make it return nothing at all, and none of them is an error:
- the project's contract has ended and it is configured to hide rather than disable;
- URL rules exclude the page it was called on;
- the config request failed, in which case it removes itself silently rather than making our outage your incident.
So if you keep the handle, check it:
const beacon = await boot({ … });
if (beacon) {
supportButton.hidden = false;
supportButton.addEventListener("click", () => beacon.openNewRequest());
}
A page that assumes a handle will throw on the one configuration where the widget was deliberately absent.
Three things you are entitled to rely on
- It never blocks your render. Everything past the launcher is lazy.
- It never touches your DOM outside its own subtree, and your CSS cannot reach it: the widget lives in a shadow root with
all: initial. A global* { box-sizing: content-box }on your page is a non-event for it. - If our API is unreachable, your page is unaffected — no thrown error, no console output, no layout shift. Bootstrap failure removes the widget and says nothing, because at that point we do not yet know whether the project even has a widget, and an error box from an unfamiliar vendor is worse than no widget.
3. Options
Every option is optional except the three above.
| Option | Type | What it does | |
|---|---|---|---|
mount | element | Renders inline into your element instead of floating. Only used when the project is configured for inline placement | |
metadata | object | Attached to every submission and shown to support staff. Internal-visibility only — see the warning below | |
css | string | A stylesheet applied inside the widget's shadow root | |
hooks | object | Event handlers, e.g. { open: fn, "ticket-created": fn } | |
expose | string \ | false | The global name the control surface is published under. Defaults to "Beacon"; pass false to publish nothing |
screenshotRenderer | function | A DOM-to-canvas function. Without one, screenshot capture is not offered |
metadata — read this before using it
It is stored indefinitely and cannot be deleted. It is the one uncontrolled input in the system: nothing validates what your page puts there. Do not put session identifiers, tokens, or special-category personal data in it. Plan tier, account age, feature flags — that is what it is for.
It is also read at submit time, from the object you passed — not copied at boot. Mutating a field on that object is picked up on the next submission; reassigning your own variable to a new object is not, because we still hold the first one. If you want the value to change during the session, mutate in place:
const supportContext = { plan: "trial" };
boot({ …, metadata: supportContext });
supportContext.plan = "enterprise"; // picked up
// supportContext = { plan: "enterprise" }; // NOT picked up — new object
css — your own styles, unfiltered
A shadow root is the one place your page's CSS cannot reach, which is the same isolation that stops us leaking into you. This is the supported way through it.
It is not filtered, and that is deliberate rather than an oversight: it lives in your own bundle, and whoever can pass it here can already append a <style> element to their own document. Filtering it would inspect your code for capabilities the code around it already has.
This is a different thing from the CSS a tenant Admin types into the Beacon admin screen. That one is parsed and filtered, because we store it and serve it to every one of that tenant's customers.
boot({
…,
css: `
.beacon-panel { border-radius: 2px; }
.beacon-entry { text-transform: uppercase; letter-spacing: .04em; }
`,
});
screenshotRenderer
Screenshot capture needs a DOM-to-canvas renderer, and one is not bundled — it would put weight in the loader for a feature not every embed uses. Pass any library with the signature (element) => Promise<HTMLCanvasElement>; demo/screenshot-renderer.js is a small SVG-based one to read.
Without it, the capture control is simply not offered. No error, no explanation — so if your users are asking where the screenshot button went, this is why.
4. Opening the widget yourself
The widget publishes a control surface, by default as window.Beacon.
document.querySelector("#contact-support")
.addEventListener("click", () => Beacon.openNewRequest());
| Call | Effect |
|---|---|
Beacon.open() | Opens the panel, as clicking the launcher would |
Beacon.openNewRequest() | Opens straight onto the new-request form |
Beacon.close() | Closes it |
Beacon.toggle() | The obvious |
Beacon.isOpen() | So you can reflect our state in your own chrome |
Beacon.on(event, fn) | Subscribe; returns a function that unsubscribes |
Beacon.off(event, fn) | Unsubscribe. Omit fn to drop every handler for that event |
Beacon.events | The event-name vocabulary, so you need not hard-code strings |
Beacon.state | The widget's current state, read-only |
Beacon.destroy() | Removes the widget and releases its listeners |
A project can switch our launcher off entirely (launcher_mode: hidden) and rely on your control instead.
Three guarantees. Nothing throws — every call is a no-op when the widget cannot honour it, so a button in your header does not need to know whether the contract has lapsed. It does not override deactivation: a project configured to hide itself stays hidden whoever calls. And it never takes a global name you are already using — if Beacon is defined, we leave it alone and publish nothing, and the handle returned by boot() still works.
5. Events
The widget dispatches ordinary DOM CustomEvents on its own element. They bubble and are composed, so listen wherever suits your architecture.
document.addEventListener("beacon:unread", (event) => {
navBadge.textContent = event.detail.unread || "";
});
| Event | detail |
|---|---|
beacon:ready | { config } — the widget has mounted and can be driven |
beacon:open / beacon:close | — |
beacon:view | { view } — the visible screen changed |
beacon:ticket-created | { id, reference, issue_type, title, status, suggested_priority, created_at } |
beacon:message-sent | { ticket_id } |
beacon:ticket-transitioned | { ticket_id, action } |
beacon:unread | { unread } |
beacon:error | { code, message } |
beacon:unread is the one most integrations actually want: it lets you badge your own navigation rather than hoping someone notices our launcher.
beacon:ready fires before boot() resolves. That is not a race you can win by awaiting — by the time you hold the handle, the event has already gone. If you want it, attach before or during boot, either with a document listener or with the hooks option:
document.addEventListener("beacon:ready", onReady); // before boot
boot({ …, hooks: { ready: onReady } }); // or here
Every other event is fine to subscribe to afterwards.
The hooks option on boot() is sugar over addEventListener, for the common case of attaching before anything can fire. Same mechanism, same rules.
What these events are not
They observe; they do not intercept. The events are cancelable: false. A preventDefault() does nothing, and there is no way for a handler to stop a customer raising a support request — that is the one outcome this product exists to prevent. If you want to add to a submission, use metadata.
Your listener never runs inside our code. That is why these are DOM events rather than callbacks we invoke: if your handler throws, the platform reports it against your function, our dispatch returns normally, and every other listener still runs. One deliberate asymmetry — an async handler whose promise rejects is reported rather than swallowed. It is your failing integration and nothing else would tell you.
6. Screenshots and redaction
Mark anything a screenshot must never capture:
<div data-support-redact>
Account 40-12-88 · sort code held on file
</div>
Those regions are painted over on the canvas, before the review step — so the user never sees the unmasked pixels either, and the unredacted image never leaves the browser. Only the confirmed image is uploaded.
Use it on payment details, other customers' data, anything a support agent has no business seeing. A screenshot of an admin portal often contains a great deal more than the bug.
7. Content-Security-Policy
If you run a CSP — and you should — the widget needs:
| Directive | Value | Why |
|---|---|---|
script-src | https://cdn.beacon.inits.dev — or your own origin, if you host the loader yourself | ES modules |
connect-src | https://api.beacon.inits.dev | The widget talks to it directly |
style-src | 'unsafe-inline' or a nonce | The widget injects its stylesheet into its own shadow root |
img-src | data: blob: | Screenshot previews are canvas data, never fetched |
Nothing here needs unsafe-eval. If a vendor asks you for it, that is worth a conversation.
8. When it does not appear
Work down this list; it is roughly in order of how often each one is the answer.
| Symptom | Cause |
|---|---|
| Nothing renders, no console output | Your origin is not on the project's allowlist. There is no wildcard support, by design — it must match exactly, scheme and port included |
| Nothing renders on some pages | The project has URL rules. This is a display rule, not a permission — the API answers on those pages exactly as elsewhere |
| It renders on a page the URL rules exclude | URL rules are evaluated once, when boot() runs. A client-side route change does not re-evaluate them. In a single-page app, boot once at the shell and call destroy() / boot() yourself if you need the rules re-applied |
| Renders, but disabled with a message | The project's contract has ended. Existing tickets stay readable; new requests do not |
401 from the session exchange | The assertion failed verification. Almost always a clock skew over 60s, a reused jti, or the wrong project_id |
| No screenshot button | No screenshotRenderer was passed |
| Requests tab missing | The project switched it off |
The widget writes nothing to the console, ever — an embedded vendor's console noise is indistinguishable from the vendor being broken. Listen for beacon:error instead.
Related
configuration.md— what a tenant Admin can change without touching any of the above