Documentation

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.com matches example.com and nothing else. It does not match www.example.com, and it does not match app.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

  1. Open the assistant, then the Deploy tab.
  2. Under Authentication Mode, choose Allowed Websites.
  3. 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.
  4. Click Save.
  5. 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:

  1. Generate a server secret in the dashboard. It starts with ss_.
  2. Store the secret on your server, alongside your other server-side secrets.
  3. When you render a page containing the widget, your backend POSTs to https://hiroi.ai/api/widget/session/create with the secret in an X-Server-Key header.
  4. Hiroi returns a signed session token.
  5. You render the token into the embed as data-session-token.
  6. The widget sends the token as X-Session-Token on every request.

Generate the server secret

  1. Open the assistant, then the Deploy tab.
  2. Under Authentication Mode, choose Session Signed.
  3. In the Server Secret section, click Generate Server Secret.
  4. 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.