Beacon

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:

  1. A backend endpoint that vouches for the signed-in user. This is the whole security model and it is the part you cannot skip.
  2. 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 your script-src. api.beacon.inits.dev — where the widget sends requests. This is what goes in your connect-src, and what you pass as baseUrl.

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

RuleWhy
Mint it server-sideA page that can mint can impersonate
120 seconds maximum lifetimeIt is exchanged for a session immediately; a long-lived assertion is a bearer token in your logs
A fresh jti every timeSingle-use. A replayed assertion is refused
org_id decides who shares historyTwo 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:

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

  1. It never blocks your render. Everything past the launcher is lazy.
  2. 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.
  3. 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.

OptionTypeWhat it does
mountelementRenders inline into your element instead of floating. Only used when the project is configured for inline placement
metadataobjectAttached to every submission and shown to support staff. Internal-visibility only — see the warning below
cssstringA stylesheet applied inside the widget's shadow root
hooksobjectEvent handlers, e.g. { open: fn, "ticket-created": fn }
exposestring \falseThe global name the control surface is published under. Defaults to "Beacon"; pass false to publish nothing
screenshotRendererfunctionA 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());
CallEffect
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.eventsThe event-name vocabulary, so you need not hard-code strings
Beacon.stateThe 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 || "";
});
Eventdetail
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:

DirectiveValueWhy
script-srchttps://cdn.beacon.inits.dev — or your own origin, if you host the loader yourselfES modules
connect-srchttps://api.beacon.inits.devThe widget talks to it directly
style-src'unsafe-inline' or a nonceThe widget injects its stylesheet into its own shadow root
img-srcdata: 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.

SymptomCause
Nothing renders, no console outputYour 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 pagesThe 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 excludeURL 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 messageThe project's contract has ended. Existing tickets stay readable; new requests do not
401 from the session exchangeThe assertion failed verification. Almost always a clock skew over 60s, a reused jti, or the wrong project_id
No screenshot buttonNo screenshotRenderer was passed
Requests tab missingThe 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.