Widget authentication
Every request the widget makes to Hiroi is authenticated. There are two modes: Allowed Websites (domain safelist) and Session Signed. This page explains how each one works, how to set it up, and how to read the errors when it fails.
Neither mode puts a secret in the browser.
You pick the mode on the Deploy tab of the assistant editor, under Authentication Mode. See Deploying your assistant for the tab itself.
Choosing a mode
| Allowed Websites | Session Signed | |
|---|---|---|
| What the page carries | data-site-id |
data-session-token |
| What proves the request | The browser's Origin header matches your allowed domains |
A short-lived token your backend minted |
| Backend work needed | None | Yes — one server-to-server call per page load |
| Knows who the visitor is | No | Yes — you supply the user identity |
| Cost | Included | Paid feature |
Use Allowed Websites for a public website. Use Session Signed when the widget lives inside an application where visitors are already signed in and you want conversations attributed to those users.
Note
Whichever mode you use, set the Domain field on the General tab to the domain where the widget is embedded. The browser's cross-origin check is made against that value, and a request from an origin that does not match it is blocked by the browser before your assistant can answer.
Allowed Websites (domain safelist)
The embed carries only the site ID:
<script async src="https://hiroi.ai/static/va-wave-widget.js"
data-site-id="YOUR_SITE_ID"></script>
The site ID is not a secret. The widget sends it as an X-Site-Id header, and Hiroi decides whether
to answer by looking at the browser's Origin header (falling back to Referer if Origin is
absent) and comparing it against the list of domains you configured.
How matching works
- Exact host, no wildcards.
example.commatchesexample.comand nothing else. It does not matchwww.example.com, and it does not matchapp.example.com. Add every host separately. - Ports. An entry without a port matches the host on any port. An entry with a port — such as
localhost:5566— must match host and port exactly.
Entries are normalized when they are saved: a leading http:// or https:// and anything after the
host are stripped, and the result is lowercased. Typing https://shop.example.com/pricing stores
shop.example.com.
Setting it up
- Open the assistant, then the Deploy tab.
- Under Authentication Mode, choose Allowed Websites.
- Type a domain into the field (placeholder:
example.com or localhost:5566) and click Add Domain. Repeat for every host the widget is embedded on. - Click Save.
- Copy the snippet under Script Tag (Site ID only — no secrets) and paste it into your site
before the closing
</body>tag.
A site with an empty Allowed Domains list rejects every request, so add at least one domain before you deploy.
When it fails
If authentication fails, the widget does not render at all — no orb, no error on the page. Add
data-debug="true" to the script tag to log the failing HTTP status to the browser console; check
the response body of the /api/widget/init request in the network panel for the reason.
| Response | Meaning | Fix |
|---|---|---|
401 Origin not in allowed domains |
The page's host is not on the list, or is on it in a different form | Add the exact host, including www. if that is how the page is served |
401 Site has no allowed domains configured |
The list is empty | Add a domain and click Save |
401 Origin or Referer header required for safelist auth |
The request arrived with no origin information | Load the widget from a real page, not from a file:// URL or a server-side fetch |
401 This site requires session token authentication |
The assistant is set to Session Signed but the page sends data-site-id |
Switch the mode back, or render a session token instead |
401 Widget site is disabled |
The assistant is turned off | Switch Assistant Status back on, on the General tab |
403 Access denied: IP not in allowlist |
The assistant has an IP allowlist and the visitor's IP is not on it | Remove or widen the allowlist |
429 Rate limit exceeded |
The assistant passed its hourly request limit | Raise Rate Limit (requests/hour) on the General tab |
Session Signed
Paid feature. Session Signed is a paid feature — add credits to your account to unlock it. On a free account the card carries a PAID badge and cannot be selected.
In this mode your backend asks Hiroi for a short-lived token, and your page renders that token instead of a site ID. The visitor's browser never sees the credential that produced the token.
The flow is:
- Generate a server secret in the dashboard. It starts with
ss_. - Store the secret on your server, alongside your other server-side secrets.
- When you render a page containing the widget, your backend
POSTs tohttps://hiroi.ai/api/widget/session/createwith the secret in anX-Server-Keyheader. - Hiroi returns a signed session token.
- You render the token into the embed as
data-session-token. - The widget sends the token as
X-Session-Tokenon every request.
Generate the server secret
- Open the assistant, then the Deploy tab.
- Under Authentication Mode, choose Session Signed.
- In the Server Secret section, click Generate Server Secret.
- Copy the value immediately. It is stored hashed, so the dashboard cannot show it again — after you reload, only the last six characters remain visible as a prefix for identification.
To roll the secret, click Regenerate Server Secret and confirm. The previous secret stops working immediately, so deploy the new one to your backend at the same time.
Warning
The server secret authenticates any request that carries it. Keep it on your server only. Never put it in client-side JavaScript, a template that is served to browsers, a mobile app, or a public repository.
POST /api/widget/session/create
Headers:
| Header | Value |
|---|---|
X-Server-Key |
Your server secret, starting with ss_ |
Content-Type |
application/json |
Body — every field is optional:
| Field | Type | Notes |
|---|---|---|
user_id |
string | Your own identifier for the signed-in user. Rejected above 255 characters. If you omit it, Hiroi generates an anonymous id for the session. |
user_name |
string | Display name. Truncated to 100 characters. |
user_email |
string | Truncated to 255 characters. |
context |
object | Arbitrary JSON carried with the conversation. Rejected with 400 if it serializes to more than 2 KB. |
usage_webhook |
object | {"url": "...", "secret": "..."}. The URL must be HTTPS and the secret must be at least 32 characters. See below. |
ttl_minutes |
number | Token lifetime. Defaults to 15, clamped to the range 1–60. A value outside the range is pulled to the nearest bound; a non-numeric value falls back to 15. |
Response:
{
"success": true,
"session_token": "eyJzaXRlX2lkIjoi....abc123",
"site_id": "6f1c9a2e-...",
"expires_at": "2026-07-19T18:42:11.930112+00:00",
"expires_in_seconds": 900
}
Errors:
| Response | Meaning |
|---|---|
401 Server key required |
No X-Server-Key header |
401 Invalid server key format |
The key does not start with ss_ |
401 Invalid server key |
The key does not match any enabled assistant — check the value, and check that Assistant Status is on |
400 |
A body field failed validation. The message names the field. |
429 Rate limit exceeded |
The assistant passed its hourly request limit |
This endpoint is rate limited to 20 requests per minute per IP address, so mint one token per page render rather than one per request from the page.
Warning
The token is signed, not encrypted. Its payload is base64-encoded and readable by anyone who has
the token — including the visitor. Put nothing confidential in user_id, user_name,
user_email or context.
Token lifetime
The token expires at expires_at. Requests made with an expired token are rejected, so choose a
ttl_minutes that covers a realistic visit, and mint a fresh token when the page is re-rendered.
The maximum is 60 minutes.
Rendering the token
<script async src="https://hiroi.ai/static/va-wave-widget.js"
data-session-token="THE_TOKEN"></script>
The loader finds its own script tag by looking for a src containing va-wave-widget. Keep the
/static/va-wave-widget.js path — a renamed or proxied path under a different filename will not
initialize. Do not also set data-site-id; when both are present the session token wins.
Complete example — Python (Flask)
import os
import requests
from flask import Flask, render_template_string
app = Flask(__name__)
HIROI_BASE = "https://hiroi.ai"
SERVER_SECRET = os.environ["HIROI_SERVER_SECRET"] # ss_...
PAGE = """<!doctype html>
<html>
<body>
<h1>Support</h1>
<script async src="{{ base }}/static/va-wave-widget.js"
data-session-token="{{ token }}"></script>
</body>
</html>"""
def mint_session_token(user):
response = requests.post(
f"{HIROI_BASE}/api/widget/session/create",
headers={"X-Server-Key": SERVER_SECRET},
json={
"user_id": user["id"],
"user_name": user["name"],
"user_email": user["email"],
"context": {"plan": user["plan"]},
"ttl_minutes": 30,
},
timeout=10,
)
response.raise_for_status()
return response.json()["session_token"]
@app.route("/support")
def support():
user = {"id": "u_8321", "name": "Dana Reed",
"email": "dana@example.com", "plan": "growth"}
return render_template_string(PAGE, base=HIROI_BASE,
token=mint_session_token(user))
Complete example — Node (Express)
import express from 'express';
const app = express();
const HIROI_BASE = 'https://hiroi.ai';
const SERVER_SECRET = process.env.HIROI_SERVER_SECRET; // ss_...
async function mintSessionToken(user) {
const res = await fetch(`${HIROI_BASE}/api/widget/session/create`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Server-Key': SERVER_SECRET,
},
body: JSON.stringify({
user_id: user.id,
user_name: user.name,
user_email: user.email,
context: { plan: user.plan },
ttl_minutes: 30,
}),
});
if (!res.ok) throw new Error(`Hiroi session create failed: ${res.status}`);
const { session_token } = await res.json();
return session_token;
}
app.get('/support', async (req, res, next) => {
try {
const user = { id: 'u_8321', name: 'Dana Reed',
email: 'dana@example.com', plan: 'growth' };
const token = await mintSessionToken(user);
res.send(`<!doctype html>
<html>
<body>
<h1>Support</h1>
<script async src="${HIROI_BASE}/static/va-wave-widget.js"
data-session-token="${token}"></script>
</body>
</html>`);
} catch (err) {
next(err);
}
});
app.listen(3000);
Both examples keep the secret in an environment variable on the server and send only the minted token to the browser.
Usage webhook
If you pass usage_webhook, Hiroi reports token and text-to-speech usage to your endpoint after each
turn of the conversation. The request is a POST with these headers:
X-Webhook-Signature: <hex hmac-sha256, using your secret, of the JSON body re-serialized with sorted keys and compact separators (`json.dumps(body, sort_keys=True, separators=(',', ':'))`)>
X-Webhook-Timestamp: <unix seconds>
The body carries session_id, user_id, site_id, a usage object
(input_tokens, output_tokens, total_tokens, tts_chars, tts_provider), a timestamp and a
nonce. Reply 200 with JSON; return {"should_continue": false} to stop the conversation — for
example when the user has run out of your own credits. Anything else, including a timeout or an
error, lets the conversation continue.
The endpoint must be HTTPS, must not resolve to a private or internal address, redirects are not followed, and the call times out after five seconds.
Warning
The webhook secret you supply is carried inside the session token, which is rendered into the page. Treat it as visible to the visitor, and use a value dedicated to this purpose.
Related pages
- Embedding the widget — the script tag and its data attributes
- JavaScript API — controlling the widget from your page
- Actions — how Hiroi calls your endpoints, and how to verify those requests