Back to docs

Embeddable chat widget

Drop PrivateRouter chat on your site with one script tag

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

  1. In your dashboard, go to Widgets → New widget.

  2. Pick a model, write a system prompt, name the widget. Save.

  3. 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>
    
  4. 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:

AttributeDefaultPurpose
data-pr-widget(required)the public widget key (wgt_pr_...)
data-pr-positionbottom-rightbottom-right or bottom-left
data-pr-greetingfrom widget configoverride the first assistant message
data-pr-hosthttps://privaterouter.comself-host or staging override

2. The widget record (created in the dashboard)

Persistent settings on the widget itself:

FieldMeaning
nameinternal label, shown in your dashboard
model_public_namewhich underlying model serves chats
system_promptprepended to every conversation
greeting_messagefirst assistant message users see
placeholdertext in the input box
theme{ primary, position } styling
allowed_domainshost allowlist for CORS (empty = any)
rate_limit_per_minuteper-widget rate cap
daily_message_limitper-widget daily message cap
statusactive 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 (matches foo.example.com but NOT the bare example.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.