API reference
Widget API
YOUR-AGENT-ID.
The Widget API is the public HTTP surface the bundled JavaScript talks to. You normally don't call it yourself — the widget loader does — but the contract is documented here so you can build custom clients, audit traffic, or simulate the widget for testing.
All endpoints are under /api/v1/widget. Authentication is
a signed JWT issued by /init. CORS is permissive on
POST for cross-origin embeds.
Driving the widget from your page (JavaScript API)
The loader puts one object on window, named
window.AIAssistant. A shop that has its own launcher, its
own in-page block and its own starter buttons uses it to send a
visitor's question into the one conversation the widget already runs —
never a second chatbot beside it. Calls made before the widget has
finished initialising are honoured the moment it has.
window.AIAssistant.open(); // open the panel
window.AIAssistant.open({ message: 'Which size do I need for the Atlas Marine?' });
window.AIAssistant.send('Is this sock seamless?'); // same as open({ message })
window.AIAssistant.close();
window.AIAssistant.isOpen(); // true | false
window.AIAssistant.setContext({ type: 'product', category: { name: 'Werksokken' }, product: { … } });
window.AIAssistant.mount(); window.AIAssistant.unmount(); // see consent, below
The name carries no product name, so it fits a white-label install.
Want your own? Put data-global="AcmeAssistant" on the
script tag and the object is published as
window.AcmeAssistant instead (letters, digits,
_ and $; a name the page already uses for
something else is ignored). The older name window.Pitchbar
still points at the same object, so a page written against it keeps
working — it is deprecated: use window.AIAssistant in new
code.
| Method | What it does |
|---|---|
open({ message?, context? }) | Opens the panel. With message, sends it as the visitor's next turn (up to 2,000 characters). With context, sets the host context first (below). |
send(message, context?) | Shorthand for open({ message, context }). |
close() | Collapses the panel to the launcher. |
isOpen() | Whether the panel is open. |
setContext(object) | Tells the agent what page this is (below). Persistent until set again. |
mount() / unmount() | Boot or dispose the widget. mount() is also what a consent banner calls, see below. |
Prefer events? The same call exists as
window.dispatchEvent(new CustomEvent('ai-assistant:open', { detail: { message: '…' } })).
The older event name pitchbar:open still works.
Telling the agent what page this is
The widget already reads the page's title, meta tags, JSON-LD and
headings. A shop knows more — and knows it exactly. Publish a global
before the script and name it on the script tag, or call
setContext() yourself (a single-page app calls it again
on every navigation):
<script>
window.blengiContext = {
type: 'product',
category: { name: 'Werksokken', slug: 'werksokken' },
product: {
name: 'STAPP Atlas Marine Sokken', sku: '26305-B-41', gtin: '8712844006095',
brand: 'STAPP', price: 10.4, currency: 'EUR', in_stock: false,
url: 'https://stappsokken.nl/stapp-atlas-marine-sokken',
description: 'De STAPP Atlas Marine sokken zijn ontworpen voor…',
attributes: { 'Samenstelling': '65% polyamide, 20% wol, 15% polyacryl', 'Naadloos': 'Ja' }
}
};
</script>
<script src="https://blengi.com/w/widget.js?v=81fa9373cded" data-agent-id="YOUR-AGENT-ID" data-context-global="blengiContext" async></script>
type and category reach the agent as where
the visitor is; product reaches it as the article on
screen — name, SKU, GTIN, brand, price, currency, availability
(in_stock is accepted), URL, description and the
attribute table — so "which size do I need" is answered for
this sock, not socks in general. Every field is bounded and
shown to the model as data, never as instructions. On a category
or home page leave product out; the global is re-read
on every mount, so a normal multi-page site needs nothing more.
A shop that sets no context gets one anyway: with every
question the widget reads the page's own Schema.org
Product and Offer JSON-LD (also inside
@graph, arrays and a nested priceSpecification)
and sends the same product fields — name, SKU, GTIN,
brand, price, currency, availability, URL and description — exactly as
the page writes them. WooCommerce, Shopify, Lightspeed and most other
shops publish it on every product page. The order: setContext()
or the context global first, then the page's JSON-LD; JSON that does
not parse is skipped. It is only context for the agent and is never
shown in the chat; whether the agent states a price still follows the
AI employee's price setting.
Consent: what is stored, and starting only after consent
The widget writes nothing until it mounts. Once it runs it keeps these, all functional — no advertising or cross-site tracking:
| Key | Where | Purpose |
|---|---|---|
pb_anon_id | cookie + localStorage | A random visitor id, so a returning visitor resumes their conversation. |
pitchbar_conv_id | cookie | The current conversation, so a reload continues it. |
pitchbar:msgs:* | localStorage | The visible thread, for an instant repaint on the next page. |
pitchbar:chrome:* | localStorage | The agent's look (name, colours), for an instant first paint. |
pitchbar:open | localStorage | Whether the visitor left the panel open or closed. |
pb_visitor_count | localStorage | How many visits, for the invitation timing. |
pb_last_trigger_at:* | localStorage | When each behaviour rule last fired, so a rule with a cooldown does not repeat. |
pitchbar:teaser:* | localStorage | That the greeting bubble was dismissed, so it does not keep reappearing. |
| auto-open flags | sessionStorage | Not opening the panel twice in one session. |
These names stay as they are, also on a white-label install: sites list them in their cookie declarations, and the WordPress plugin reads the conversation cookie to credit orders to the chat. Renaming them would break both.
To start the chat only after the visitor consents, add
data-mount="manual" to the script tag. The script then
loads and does nothing — no element in the page, no cookie, no
storage — until you call window.AIAssistant.mount() from
your banner's accept handler (or from Google Consent Mode's update).
Whether these functional keys need consent at all is the site
owner's call; the switch exists either way.
Using your own launcher
A shop with its own chat button does not want ours beside it. Add
data-launcher="none" to the script tag and the widget
renders no floating launcher and no greeting bubble: closed, it is
invisible; your button calls AIAssistant.open() and the
panel appears; closing it returns to invisible. Everything else —
the panel, the conversation, in-page blocks — is unchanged.
mount() is safe to call more than once: a page that
already mounted and calls it again from a consent handler gets the
same widget, not a second one.
open() and send() mount the widget
themselves if it has not been mounted yet — a page that calls them
from its own button is asking for the chat, so it appears. Nothing
still happens on its own: with data-mount="manual" the
widget waits until your page calls something.
<script src="https://blengi.com/w/widget.js?v=81fa9373cded" data-agent-id="YOUR-AGENT-ID" data-mount="manual" async></script>
<script>
onConsentGranted(() => window.AIAssistant.mount());
</script>
Identify signed-in customers
A customer who is signed in to your site can be recognised by the AI employee — on every device, never mistaken for someone else — when your server vouches for them. Generate the identity secret under Settings › Widget › Identify signed-in customers, then put this on every page where a customer can be signed in, before the widget's script tag:
<script>
window.aiAssistantUser = {
id: "123", // your customer id
email: "anna@example.com", // optional
name: "Anna", // optional
signature: "…" // HMAC-SHA256 of the id with the secret, as hex
};
</script>
The signature is made on your server, never in the page. PHP:
hash_hmac('sha256', (string) $user->id, $secret); Node.js:
createHmac('sha256', secret).update(String(user.id)).digest('hex');
Shopify Liquid: {{ customer.id | append: '' | hmac_sha256: secret }}.
The WordPress plugin, the
Shopify app and the
Laravel package print the object for
you once the secret is in their settings. source is optional
(woocommerce, wordpress, shopify,
botble, laravel); a customer is known by the
id within a source, so always send the same source from one system.
A missing or wrong signature makes the visitor anonymous — a forged object shows nobody's history, and never causes an error; its name and address only pre-fill the lead form (the three levels of trust). With a valid one the conversation is marked as that customer's. When customer memory is on for the workspace the customer becomes a contact: the same customer on a second device resumes the thread they started on the first (within the day), and the anonymous conversations they had on this device before signing in are attached to them. The e-mail address and name are kept as your site gave them; only the id and the signature identify the customer. Rotating the secret keeps the old one working for seven days; removing it switches the recognition off.
POST POST /v1/widget/init
Boots the widget for a visitor. No auth — but the request's
Origin header must match the agent's
allowed_origins (see Allowed origins).
Request
POST /api/v1/widget/init
Origin: https://your-site.com
Content-Type: application/json
{
"agent_id": "01HXY...",
"page_url": "https://your-site.com/pricing",
"anon_id": "anon_abc123", // optional; persists visitor across reloads
"user": { "id": "123", "signature": "…", "email": "…", "name": "…" } // optional; see "Identify signed-in customers"
}
Response (200)
{
"data": {
"conversation_id": "01HXZ...",
"visitor_id": "01HXY...",
"anonymous_id": "anon_abc123",
"jwt": "eyJhbGciOiJIUzI1NiI...",
"expires_at": "2026-05-07T13:00:00Z",
"agent": {
"id": "01HXY...",
"name": "Aria",
"persona": { "name": "Aria", "tone": "friendly" },
"theme": { "primary": "#111827", ... },
"starter_prompts": [ "..." ],
"language_default": "en"
},
"branding": { "show": true, "label": "...", "url": "...", "logo_url": "...", "display_mode": "logo_only" },
"behavior_rules": [ ... ],
"messages": [ ... ], // last 30 messages of the resumed conversation
"reverb": { "app_key": "...", "host": "...", "port": 8080, "scheme": "wss" }
}
}
Error responses
| Status | Code | Cause |
|---|---|---|
| 404 | agent_not_found | Agent doesn't exist or isn't published. |
| 403 | origin_forbidden | Origin not in allowed_origins. |
| 429 | plan_limit_reached | Workspace exceeded its monthly conversation quota. |
| 429 | rate_limited | Init rate limit hit: 1,000 per minute per IP + agent, or 30,000 per hour per IP. |
POST POST /v1/widget/messages/stream
The streaming endpoint. SSE response. Auth: Authorization: Bearer
<jwt>. Use this for the visitor experience — every other
method is sync and slower.
Request
POST /api/v1/widget/messages/stream
Authorization: Bearer eyJhbGciOiJIUzI1NiI...
Content-Type: application/json
{
"message": "What's your refund policy?", // required, up to 4,000 characters
"page_context": { ... } // optional; structured data extracted from the current page
}
Response (Server-Sent Events)
HTTP/1.1 200 OK
content-type: text/event-stream
: heartbeat 1714900000.123
event: start
data: {"conversation_id":"0199…","message_id":"0199…"}
event: stage
data: {"s":"searching"}
event: token
data: {"t":"Our "}
event: token
data: {"t":"refund policy is 30 days."}
event: done
data: {"text":"Our refund policy is 30 days.","citations":[…],"cta":null,"ctas":[],"lead_prompt":false,"low_confidence":false,"latency_ms":840}
Each event is a named SSE event (event: line) with a JSON
data: line. Besides start, stage,
token and done, a turn can carry
tool_call, block (product, pricing and other
cards, sent before done) and error
({"code": "…", "message": "…"}). Lines starting with
: are heartbeats that keep proxies from buffering. There is
no separate citations event: the citations ride in done,
which ends the turn. Token events come fastest in the first few hundred
ms — that's the 1-second-to-first-token target on the hot path.
POST POST /v1/widget/messages
Sync version of /messages/stream. Returns the full response
in one JSON payload. Slower (visitor waits for the full response) but
easier to integrate with non-browser clients.
Response
{
"data": {
"message_id": "01HXZ...",
"conversation_id": "01HXZ...",
"content": "Our refund policy is 30 days...",
"citations": [{"id": 1, "url": "..."}],
"low_confidence": false
}
}
POST POST /v1/widget/leads
Submit captured contact info. Auth: same JWT as messages.
POST /api/v1/widget/leads
Authorization: Bearer eyJhbGciOiJIUzI1NiI...
{
"name": "Alex",
"email": "alex@example.com",
"phone": "+1...",
"fields": { "company": "Acme" } // any agent-defined custom fields
}
Dedupes on (agent_id, email): repeat submissions update the existing lead instead of creating a new one. Rate-limited at 30 requests per minute per widget token.
POST POST /v1/widget/events
Lightweight client-side analytics. The widget calls this with telemetry events (launcher opened, CTA clicked, dismissed, scroll trigger fired). Auth: JWT. Rate-limited.
{
"events": [ // required, up to 100 per call
{ "kind": "cta.clicked", "payload": { ... } }, // kind: up to 64 characters
{ "kind": "trigger.fired", "payload": { "rule_id": "0199…", "kind": "exit_intent" } }
]
}
POST POST /v1/widget/request-human
Visitor escalates to a live operator. The bot's reply pauses; the
conversation flips to human_requested_at and the
chat shows under Conversations › Needs you, with the count on the
menu, for every team member. Auth: JWT.
No body is needed; the endpoint reads none. When handover is closed
for the agent it answers 422 with
status: "handoff_unavailable" and the agent's fallback
(message, optional link and form) instead.
POST POST /v1/widget/typing
Visitor typing indicator. The widget pings while the visitor is composing so operators see "is typing…" in real time. Throttled to 600 rpm per IP (high to absorb keystroke bursts; per-IP instead of per-JWT so multiple tabs share the budget). No body required — a bare POST is enough.
POST POST /v1/widget/satisfaction
Capture the visitor's CSAT rating at end of conversation. Auth: JWT. Throttled to 60 rpm per IP.
{
"rating": "positive", // positive | negative
"comment": "Loved the help" // optional, max 2,000 chars
}
The first rating is kept; a later call only updates the comment.
POST POST /v1/widget/coupon/apply
E-commerce vertical only. Visitor accepts an offered coupon CTA; the controller stamps the conversation with the coupon code so downstream attribution can credit the bot. Auth: JWT. Throttled to 120 rpm per IP.
{ "code": "SAVE20" } // required, up to 120 characters
JWT format
HS256, signed with the platform's secret. Claims:
{
"iss": "pitchbar",
"iat": 1714900000,
"exp": 1714903600, // 60 minutes
"agent_id": "01HXY...",
"visitor_id": "01HXY...",
"conversation_id": "01HXZ..."
}
Tokens are scoped to a single conversation. Re-init to get a fresh
token for a new conversation. Verifying happens in
WidgetJwt::verify() — invalid signatures, expired tokens,
or tampered claims all return 401.
Rate limits
| Endpoint | Limit | Key |
|---|---|---|
/init | 1,000 per minute, and 30,000 per hour | per IP + agent_id per minute, per IP per hour (throttle:widget-init) |
/messages, /messages/stream, /events, /conversation/*, /request-human, DELETE /me and the other token endpoints | 300 per minute | per widget token (throttle:widget-session) |
/leads | 30 per minute | per widget token (throttle:widget-leads) |
All return 429 with a Retry-After header on limit.