Embed the widget
Allowed origins
The widget script is public on purpose — anyone can fetch
/widget/widget.js. That makes allowed origins
the trust boundary that stops a third party from embedding your snippet
on their own site and burning your quota.
The contract
Every POST /v1/widget/init reads the request's
Origin header (or Referer as a fallback) and
checks it against the agent's allowed_origins list. The
rules are:
- Empty list → 403. Deny everywhere. New agents start empty until you add at least one origin.
- Wildcard
"*"→ allow. Opt-in escape hatch for internal tools and demos. Never set as a default. - Otherwise → normalised
scheme://hostmatch, made forgiving so you enter the domain once:- A bare domain with no scheme (
stappsokken.nl) defaults tohttps. wwwand non-wwware the same — listingexample.com,www.example.com, orhttps://example.comall match bothhttps://example.comandhttps://www.example.com.- Every other subdomain stays distinct.
https://example.comstill does not permithttps://app.example.com—wwwis the only subdomain treated as the same site.
- A bare domain with no scheme (
Every privileged call re-checks Origin
The init endpoint is where the JWT gets minted — but the JWT is
a bearer token, and bearer tokens can be exfiltrated (leaked
log, browser dev-tools, XSS on a victim site, MITM on
cleartext). To stop a stolen JWT from being replayed from
attacker.example, every privileged widget endpoint
runs through the VerifyWidgetOrigin middleware,
which re-validates the request Origin against the JWT-bound
agent's allowed_origins on every call:
POST /v1/widget/messagesPOST /v1/widget/messages/stream(SSE)POST /v1/widget/leadsPOST /v1/widget/request-humanPOST /v1/widget/eventsPOST /v1/widget/typingPOST /v1/widget/satisfactionPOST /v1/widget/coupon/applyPOST /v1/widget/conversation/clearGET /v1/widget/conversation/messagesDELETE /v1/widget/me
Policy is identical to /v1/widget/init — empty
list = deny, "*" = allow (including no-Origin),
specific entries = exact normalised match — so a request that
init would have approved can never be 403'd by the post-init
middleware, and a request that init would have denied can never
sneak past either.
Strict subdomain matching
This is the rule that catches people off guard, so it deserves its own callout:
https://thecodestudio.com in
allowed_origins does not permit
https://pitchbar.thecodestudio.com. Subdomains are
independent — list each one explicitly. This prevents an attacker
who controls a subdomain (via DNS or shared hosting) from inheriting
trust from the parent. The one exception is www:
www.example.com and example.com are treated as
the same site, so you never list both.
If you actually want additional subdomains, list them individually
(www is already covered):
https://example.com
https://app.example.com
https://docs.example.com
Adding origins
From the agent's Settings page (/app/agents/{id}/settings),
the Allowed origins card has a textarea — one origin per
line. Save updates the agent immediately; new init requests use the new
list within seconds.
You can enter just the domain — the scheme defaults to https
and www is matched automatically. How a few inputs are read:
| You enter | Matches |
|---|---|
example.com | https://example.com and https://www.example.com |
www.example.com | same as above (www and non-www) |
http://localhost:3000 | http://localhost:3000 — keep the scheme + port for local http development |
*.example.com | nothing — wildcards aren't supported except as a lone "*" |
Testing locally
During development, add http://localhost:3000 (or whatever
port you're using) to the agent's allowed origins. Don't use
"*" for this — leaving it on by accident in production
leaves the agent open.
What happens on rejection
A request from a disallowed origin gets a JSON 403:
{
"error": {
"code": "origin_forbidden",
"message": "Origin is not allowed for this agent."
}
}
The widget's loader handles this gracefully — the launcher disappears silently rather than throwing a console error, so visitors never see a broken UI. The 403 is logged on the platform side so you can watch for abuse patterns.
What about same-host origins
The check uses scheme + host, so http vs. https
is distinct (as it should be). And different ports are different
origins (http://localhost:3000 ≠
http://localhost:3001).
Wildcards: when to use, when not
"*" exists for cases where you genuinely don't know the
origin in advance:
- Internal demo agents that get embedded on every prospect's preview site.
- Sandbox / preview environments where origin churns daily.
For production agents, never. The cost of forgetting "*" is
that anyone who finds your data-agent-id can drain your
quota. The cost of an explicit list is one minute per new origin.
Where should this agent appear? — the path-level companion
Allowed origins draws the trust boundary at the
domain level (only https://shop.example.com can load
the widget). Where should this agent appear? is its
sibling, one level down: which paths within an allowed origin
the agent shows on. One list of patterns, read one of three ways:
| Mode | The agent appears on | Typical use |
|---|---|---|
| Everywhere | every page of the allowed domains; the list is ignored | the default |
| Everywhere except these paths | every page but the ones a pattern matches | keep the bot off your own /admin, /checkout or /account flows |
| Only on these paths | the pages a pattern matches, and nowhere else | two specialised agents on one site — one only on /waterhardheid/*, the other everywhere except it |
Each entry is a glob — * is the only wildcard, and
matches across slashes greedily. Comparison is case-insensitive
against window.location.pathname:
| Pattern | Matches | Doesn't match |
|---|---|---|
/admin |
/admin, /Admin |
/admin/users (use /admin/* for that) |
/waterhardheid/* |
/waterhardheid, /waterhardheid/, /waterhardheid/gulpen-wittem/ |
/feiten-en-fabels/waterhardheid |
/checkout |
/checkout |
/checkout/confirm |
/account/* |
/account/profile, /account/security |
/Help/account |
How it works at runtime
The agent's path_mode and restricted_paths
ride the same POST /v1/widget/init response as the rest
of the config, and the cached copy the widget paints from on a
return visit carries both, so a page that is out never flashes the
bar for half a second. When the page is out, the bar never mounts,
the trigger engine never starts, and no further HTTP rides on that
page. The init call itself does happen (the server is the source of
truth).
Only on these paths with an empty list shows the agent everywhere rather than nowhere: picking the mode before typing a pattern must not make an agent vanish from the whole site.
Two agents on one website
Give the section agent Only on these paths with
/waterhardheid/*, and the general agent Everywhere
except these paths with the same pattern. Both scripts can then
be installed on every page — through the theme or a tag manager
with an All Pages trigger — and each agent decides for
itself whether this page is its own. On a page where the first
script's agent is out, it hands the page to the other, so a visitor
sees one widget and never two, and never none. One page, one
/init per tag, as before. No path lookup in the tag
manager is needed for that; see Install
snippet for the tag-manager recipe if you prefer routing there.
Authoring
From the agent's Settings page, the Where should this agent appear? card has the three options and, for the second and third, a textarea with one path per line. Up to 32 entries, each up to 200 characters. Agents that had restricted paths before this setting existed were moved to Everywhere except these paths automatically, so nothing changed for them.
Why this is a separate knob from auth
The platform also auto-suppresses the marketing demo widget on
authenticated admin/customer routes via a server-side check in the
Inertia root layout — that's a hard guarantee that doesn't depend
on agent config. restricted_paths is the buyer-side
extension: even on a fully unauthenticated marketing site,
/checkout shouldn't be cluttered with a sales chat bot.
How auto-index uses origins
Auto-index uses the same
allow-list, but with a twist when "*" is set: the page URL
being auto-indexed must match the visitor's actual Origin
header. That stops a malicious page from auto-indexing arbitrary
third-party domains via the wildcard.