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
| Setting | Values | Default |
|---|---|---|
orientation | vertical, horizontal | vertical |
placement | floating, inline | floating |
launcher_corner | bottom-right, bottom-left, top-right, top-left | bottom-right |
launcher_offset_x / _y | 0–400 px | 24 |
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
| Setting | Values | Default |
|---|---|---|
launcher_shape | rounded, circle, square, pill | rounded |
launcher_icon | none, chat, question, lifebuoy, envelope, sparkle | none |
launcher_label | plain text, ≤ 40 characters | our own wording |
launcher_mode | launcher, hidden | launcher |
theme_tokens | primary, 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
| Setting | Effect |
|---|---|
show_requests_tab | Whether the widget offers the request history. Default on |
show_help_tab | Whether 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:
| Refused | Why |
|---|---|
@import, @font-face | Both 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 selectors | Rules must be anchored on a beacon- class or id, so your CSS styles the widget and nothing else |
| Any backslash | CSS 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.
| Value | The Requests tab shows |
|---|---|
organisation | Every ticket in their organisation. The default |
organisation_open | The same, minus resolved and closed |
own | Only tickets this person raised |
own_open | The same, minus resolved and closed |
none | Nothing. 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.
- A pattern is a path with
*wildcards —/support/*. Not a regular expression: a tenant regex would run in your own customers' browsers, where a catastrophic backtrack is their tab freezing. - With an allow list, the widget renders only on matching pages.
- With a deny list, it renders everywhere except matching pages.
- Deny wins. A URL on both lists does not render.
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 paths live in
sessionStoragein that tab and go nowhere until the customer submits a request. Close the tab and they are gone. - They are cleared the moment a request is sent. The next request starts empty.
- Sensitive query parameters — anything whose name contains
token,code,session,key,password,emailand a dozen others — are replaced with[redacted]in the browser, before anything is transmitted. So are credentials in the URL and the fragment. - Your
url_denyrules apply. A page the widget does not render on is a page it does not record. - Consecutive reloads of the same page collapse into one entry, and the timestamp stays at the first arrival — so the trail shows where somebody was stuck.
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.
Related
integration.md— the host-page side