Beacon

For admins changing how a tenant's widget looks and behaves

Configuring a project

Everything here is changed by an Admin and takes effect on the next page load in the customer's portal. None of it requires a code change on the host page — that is the point of it being server-side.

For the embed itself, see integration.md.


Widget appearance

PUT /internal/admin/projects/{id}/widget-config

The PUT replaces the whole document. A call that omits allowed_origins clears it, and a project with no allowlisted origin refuses its own widget's requests — read, modify, write.

There is a dry run: POST …/widget-config/validate reports exactly what a save would accept or refuse, and writes nothing.

Where it goes

SettingValuesDefault
orientationvertical, horizontalvertical
placementfloating, inlinefloating
launcher_cornerbottom-right, bottom-left, top-right, top-leftbottom-right
launcher_offset_x / _y0–400 px24

inline renders into an element the host page provides, rather than floating over it. The host has to pass mount for that to do anything.

What it looks like

SettingValuesDefault
launcher_shaperounded, circle, square, pillrounded
launcher_iconnone, chat, question, lifebuoy, envelope, sparklenone
launcher_labelplain text, ≤ 40 charactersour own wording
launcher_modelauncher, hiddenlauncher
theme_tokensprimary, on_primary, surface, on_surface, border_radius

A circle keeps its label. If you choose a circular launcher with an icon, the label is still read to a screen reader — a button with no accessible name is one that some people cannot identify.

launcher_mode: hidden means no way in unless you build one. The host page must call Beacon.open() from a control of its own. The save response says so.

Theme tokens are contrast-checked when you save them. Every foreground and background pair must reach 4.5:1, and a failing pair is refused with the ratio it achieved. An unconstrained colour picker produces unreadable widgets inside a customer's portal, and that accessibility failure lands on us.

What appears in it

SettingEffect
show_requests_tabWhether the widget offers the request history. Default on
show_help_tabWhether it offers FAQ content. Default on

New Request cannot be switched off. A widget with no way to raise a request is not this product; a project that wants that should be deactivated instead.

Switching off Requests does more than hide a tab: ticket detail is reached only from that list, so the widget opens onto the form and shows the reference in place after sending. Otherwise it would be a reference the customer never sees.

Both compose with the deactivation rules — a tab appears only if you show it and the project's contract still permits reading it.

These are display settings, not permissions. Hiding the Requests tab does not stop the tickets endpoint answering, and excluding a page does not stop a host page booting the widget there. Access is decided server-side from the session and its scopes, and nothing on this screen reaches it. Anything that must be prevented belongs in roles and scopes. It is put this bluntly, and asserted by tests, because it is the thing most likely to be misread.


Your own CSS

custom_css accepts a stylesheet applied inside the widget's shadow root, after ours — so a rule of equal specificity wins.

It is parsed and filtered when you save it, not sanitised at render. A refusal names the construct so you can fix it:

RefusedWhy
@import, @font-faceBoth fetch, from inside your customer's browser session
url(...) other than data:image/…A remote url() is an outbound request that fires on render — a tracking beacon, whatever the intent
expression(), behavior:, -moz-binding:, javascript:Routes from CSS to script execution
:host, :host-context(), bare *, element selectorsRules must be anchored on a beacon- class or id, so your CSS styles the widget and nothing else
Any backslashCSS escapes can spell out any of the above in a form no text matcher sees
Over 20 KB

Anything the filter has never heard of is refused rather than allowed by omission, so a CSS feature invented after this was written fails closed.

Custom CSS ends our accessibility guarantee. Theme-token contrast is still checked, but no save-time check can prove an arbitrary stylesheet holds 4.5:1 across every state of every screen. A project with custom CSS carries no WCAG AA conformance from us — the save response tells you so at the moment you save, rather than leaving a guarantee standing that we had stopped being able to make. Contrast, focus visibility and target size on that project become yours to hold. If you would rather keep the guarantee, stay on tokens.


What customers can see

PATCH /internal/admin/projects/{id} · {"ticket_visibility": "own"}

By default a customer sees every ticket raised by their organisation. That is right when your customers are companies and a support request is about the account. It is wrong when your customers are individuals who happen to share an organisation in your data — then one person's support history is visible to another.

ValueThe Requests tab shows
organisationEvery ticket in their organisation. The default
organisation_openThe same, minus resolved and closed
ownOnly tickets this person raised
own_openThe same, minus resolved and closed
noneNothing. The tab is absent and the widget only raises requests

This one is enforced, not displayed. Unlike the tab switches above, narrowing this does not merely hide a screen: the list, the ticket, its message thread and its attachments are all refused, and a request for a ticket outside scope answers 404 rather than 403 — we do not confirm that a ticket you cannot see exists.

Why PATCH and not the widget-config PUT. The widget-config call replaces the whole document, so a save that omitted this field would reset it — meaning an admin adjusting a launcher colour could silently widen a project from own back to organisation. A setting whose failure mode is disclosure does not go on a whole-document save. It has its own call, and an absent field is left alone.

The _open variants hide more than they look like they hide. A customer who cannot see a resolved ticket also cannot reopen it — so a request that was closed in error becomes a new request rather than a reopened one. That is usually fine, and it is worth knowing before you choose it. The save response says so.

The About tab

Off unless you switch it on. A heading and up to twelve paragraphs of plain text — support hours, what this channel is and is not for, where to go when something is down, a link to your status page.

Markup is refused when you save it rather than escaped when it renders, so you find out here rather than when a customer sees a literal <b> on screen. If you want the panel styled, that is custom CSS; if you want something longer than a screen, that is the FAQ, which is versioned and searchable.

Switching the tab on with nothing in it is refused — that combination is always a mistake.

One thing worth deciding before you write it. Nothing stops you putting a response time in here, and we do not check for one. It is your tab and your commitment to your customers. But it is your commitment: it is not connected to your support targets, nothing reconciles the two, and a customer who reads "within four hours" here will quote it back to you. Support hours and an escalation path are what this is for; a number you have not agreed internally is a number worth leaving out.

Where the widget appears

url_allow and url_deny take up to 20 patterns each, matched against the host page's path and query.

When excluded, the widget is absent — not hidden, not disabled, not present at all. Again: this governs rendering, not access.


The pages before the report

PATCH /internal/admin/projects/{id} with {"track_page_trail": true}

Off by default. When you switch it on, a request carries the last three pages the customer visited in that browser tab.

This is the setting that turns "clicking Export failed" into "filtered to Q3, opened an invoice, went back, clicked Export" — which is the difference between a bug you can reproduce and one you cannot. It is also the one setting here that collects something about a person rather than about your product, so it is worth knowing exactly what it does.

Three, and it is not a number you can raise. Three is what fits on the form, and the form shows the customer the exact paths before they send. That disclosure is what makes this defensible without a consent dialogue nobody reads. A longer list would be a browsing history rather than a reproduction.

What actually happens:

The customer sees a section on the form headed "Pages included with this request" listing the paths, with the redactions visible. They are not asked to approve it per request: you enabled it for your own portal and your own customers, and a per-submission opt-out would be us overruling that from inside your product. What is owed is that nothing is sent they were not shown.

Your support team sees the route in the ticket and in the notification email, under "How they got there", with each page's offset from the moment of the report — relative, not wall-clock, so the record does not also state when somebody was at their desk.

Switching it off stops collection immediately, including for people already mid-session. It does not remove trails already attached to existing requests: nothing in Beacon is ever deleted.


Who gets notified

PUT /internal/admin/projects/{id}/notification-recipients

Up to 25 email addresses told whenever a request is raised.

These are in addition to your support team, not instead of them. There is a second setting — a project's notification_address — that does replace the per-agent fan-out. The two look alike and do different things.

Nominated recipients receive the customer-facing view: the reference, type, the customer's own priority, the full description, supporting information, the reporter, attachments, the page they were on, and the screenshot if one was captured. They do not receive SLA state, the support-set effective priority, the assignee, internal notes, or the Jira key.

That last part is not an oversight. A watcher sits outside the support organisation, and a response target quoted to them becomes a commitment nobody agreed to.

A recipient is not an account. There is no login and no access to anything; they receive mail and nothing more.


Signing secrets

POST /internal/admin/projects/{id}/secrets/rotate

Returned once and never again. Two secrets are live at a time with a 30-day overlap, so rotating one does not break a portal mid-deploy: update the host's copy any time inside the window.


Allowed origins

Exact matches only — scheme, host and port. No wildcards, at any level.

https://portal.acme.com and https://portal.acme.com:8443 are different origins, and so are http:// and https:// versions of the same host. This is the check that stops another site embedding your project's widget.


What is audited

Every change on this page is recorded with the actor, the project, and what changed. The per-ticket audit log is separate and cannot be edited or deleted by anyone, including us — that is enforced by database triggers rather than by convention.