Embeddable chat widget
Drop a fully-styled PrivateRouter chat assistant onto your website with
a single <script> tag. The widget loads in a sandboxed iframe, talks
to your PrivateRouter account in the background, and charges your
balance — your visitors never see PrivateRouter login, never pay
anything, and never know what's powering it.
Why use this
- One-line embed: 1
<script>tag. - Multi-tenant: a single PrivateRouter account can run unlimited widgets, each with its own model, prompt, rate limit, and budget.
- Domain-locked: optionally restrict each widget to specific hostnames so a leaked public key can't be hotlinked.
- Per-widget billing: every widget's traffic shows up as a separate usage line, so you can attribute cost to the customer who's using each chatbot.
- No SDK, no React, no build step: the loader is a ~10 KB vanilla JS file.
Quickstart
-
In your dashboard, go to Widgets → New widget.
-
Pick a model, write a system prompt, name the widget. Save.
-
Copy the embed code from the widget detail page. It looks like:
<script src="https://privaterouter.com/widget/v1.js" data-pr-widget="wgt_pr_kXxQzL9b8mY3aN2fHvJp4Wc7" async></script> -
Paste anywhere in your site's HTML —
<head>, end of<body>, inside a CMS block, whatever's convenient.
That's it. A floating chat bubble appears in the bottom-right; visitors click it, get the iframe chat UI, and talk to your configured model.
Anatomy
A widget is configured by three layers:
1. The script tag (set per page)
Optional attributes on the <script> element override defaults for
just that page:
| Attribute | Default | Purpose |
|---|---|---|
data-pr-widget | (required) | the public widget key (wgt_pr_...) |
data-pr-position | bottom-right | bottom-right or bottom-left |
data-pr-greeting | from widget config | override the first assistant message |
data-pr-host | https://privaterouter.com | self-host or staging override |
2. The widget record (created in the dashboard)
Persistent settings on the widget itself:
| Field | Meaning |
|---|---|
name | internal label, shown in your dashboard |
model_public_name | which underlying model serves chats |
system_prompt | prepended to every conversation |
greeting_message | first assistant message users see |
placeholder | text in the input box |
theme | { primary, position } styling |
allowed_domains | host allowlist for CORS (empty = any) |
rate_limit_per_minute | per-widget rate cap |
daily_message_limit | per-widget daily message cap |
status | active or deleted (soft-delete) |
3. The public key
A wgt_pr_* token (wgt_pr_ + 24 url-safe base64 chars = 96 bits of
entropy). Safe to put in public HTML as long as you've set
allowed_domains. If you haven't, rotate it the moment you see it
abused.
Origin allowlisting
Set allowed_domains to an array of hostnames:
["docs.example.com", "*.example.com"]
- Exact match:
docs.example.com - Wildcard subdomain:
*.example.com(matchesfoo.example.combut NOT the bareexample.com) - Scheme/path stripped: just hostnames
When a widget chat call arrives with an Origin header that doesn't
match any allowlist entry, the API returns 403 widget_origin_denied.
Leave allowed_domains empty during development, lock it down before
launch.
Rate limits & daily caps
Two layers protect the widget from cost runaway:
- Per-minute rate limit — sliding 60-second window per widget.
Default 10 messages/minute. Exceeding it returns 429
widget_rate_limit. - Daily message cap — total messages this widget can serve in a
UTC day. Default 200. Exceeding it returns 429
widget_daily_cap_exceeded. Resets at 00:00 UTC.
For a customer-facing assistant, set the daily cap based on your balance / cost-per-message. For a documentation Q&A bot, set something generous like 5,000.
Billing
Every widget chat is billed to your PrivateRouter account at the
served model's normal token price. Failed deliveries (404 widget,
origin denied, rate limited, daily cap) are free. Successful chats
go through the same usage_event pipeline as any API request —
they show up on /usage and on the per-widget usage chart, and they
count toward your alerts (M12).
When your account balance reaches $0, the widget returns 503
widget_owner_insufficient_balance instead of charging. Top up to
re-enable.
Self-hosting the loader
The default loader URL is https://privaterouter.com/widget/v1.js.
For an air-gapped deploy, host the same file at your own URL and
point the data-pr-host attribute at your PrivateRouter API base:
<script src="https://your-cdn.example.com/pr-widget.js"
data-pr-widget="wgt_pr_..."
data-pr-host="https://api.privaterouter.your-company.com"
async></script>
The iframe is served from the same host you configured.
Rotating the public key
If a public key leaks (someone copies your embed code onto a different site that you've allowlisted, e.g.) hit the Rotate key button on the widget's dashboard page. The old key stops authenticating immediately. Then update the embed code on every page using the new value.
API reference
Public (no auth)
GET /api/widget/{public_key}/config
POST /api/widget/chat body: { public_key, messages, session_id?, stream? }
Member-side (session auth)
GET /api/widgets
POST /api/widgets
GET /api/widgets/{id}
PATCH /api/widgets/{id}
DELETE /api/widgets/{id}
POST /api/widgets/{id}/rotate-key
GET /api/widgets/{id}/usage?window_days=7
Threat model
Things to know:
- The public key is not a secret — it's designed to live in
public HTML. The defence layers are
allowed_domains+ rate limits- daily caps + your account balance.
- The widget cannot pull data from your other PrivateRouter resources (chat history, other widgets, your API keys). It's scoped to its own widget record only.
- The widget iframe is sandboxed: the parent page can't read its contents (it's served from the PrivateRouter domain by default).
- The widget can't be silently moved to a different model — only the
widget owner can change
model_public_name.
Limits & future work
- No file upload / RAG (saved prompts in M18 will help)
- No streaming response display in v1 (request returns when complete)
- No multi-language i18n
- No voice / TTS
These are roadmap items. Current widget is intentionally minimal so the surface area stays small and easy to embed.