B Blengi docs

API reference

Widget API

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. 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.Pitchbar.open();                                   // open the panel
window.Pitchbar.open({ message: 'Which size do I need for the Atlas Marine?' });
window.Pitchbar.send('Is this sock seamless?');            // same as open({ message })
window.Pitchbar.close();
window.Pitchbar.isOpen();                                  // true | false
window.Pitchbar.setContext({ type: 'product', category: { name: 'Werksokken' }, product: { … } });
window.Pitchbar.mount();  window.Pitchbar.unmount();       // see consent, below
MethodWhat 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('pitchbar:open', { detail: { message: '…' } })).

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="…/widget/widget.js" data-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.

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:

KeyWherePurpose
pb_anon_idcookie + localStorageA random visitor id, so a returning visitor resumes their conversation.
pitchbar_conv_idcookieThe current conversation, so a reload continues it.
pitchbar:msgs:*localStorageThe visible thread, for an instant repaint on the next page.
pitchbar:chrome:*localStorageThe agent's look (name, colours), for an instant first paint.
pitchbar:openlocalStorageWhether the visitor left the panel open or closed.
pb_visitor_countlocalStorageHow many visits, for the invitation timing.
pb_last_trigger_at:*localStorageWhen each behaviour rule last fired, so a rule with a cooldown does not repeat.
pitchbar:teaser:*localStorageThat the greeting bubble was dismissed, so it does not keep reappearing.
auto-open flagssessionStorageNot opening the panel twice in one session.

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.Pitchbar.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 Pitchbar.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="…/widget/widget.js" data-agent-id="…" data-mount="manual" async></script>
<script>
  onConsentGranted(() => window.Pitchbar.mount());
</script>

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
}

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

StatusCodeCause
404agent_not_foundAgent doesn't exist or isn't published.
403origin_forbiddenOrigin not in allowed_origins.
429plan_limit_reachedWorkspace exceeded its monthly conversation quota.
429(throttled)Per-IP rate limit hit (60 rpm by default).

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?",
    "page_url": "https://your-site.com/pricing",
    "page_context": { ... }       // optional; structured data extracted from the current page
}

Response (Server-Sent Events)

HTTP/1.1 200 OK
content-type: text/event-stream

data: {"event":"token","token":"Our "}

data: {"event":"token","token":"refund "}

data: {"event":"token","token":"policy is 30 days "}

data: {"event":"citations","citations":[{"id":1,"url":"https://your-site.com/refunds"}]}

data: {"event":"done","conversation_id":"01HXZ..."}

Token events come fastest in the first few hundred ms — that's the 1-second-to-first-token target on the hot path. citations event arrives once after streaming completes; done closes the stream.

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 5 requests per JWT per window — abuse-resistant.

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.

{
    "event": "cta.click",
    "rule_id": "01HXY...",
    "metadata": { ... }
}

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 in-app Inbox alerts every workspace operator. Auth: JWT.

{ "reason": "I'd like to talk to sales" }   // reason optional, max 500 chars

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": "good",                  // good | bad
    "comment": "Loved the help"        // optional, max 500 chars
}

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" }                   // 3-32 chars, alphanumeric + dashes

JWT format

HS256, signed with WIDGET_JWT_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

EndpointLimitKey
/init60 rpmper IP + agent_id (throttle:widget-init)
/messages, /messages/stream, /events, /conversation/*, DELETE /me30 rpmper JWT (throttle:widget-session)
/leads5 rpmper JWT (throttle:widget-leads)

All return 429 with a Retry-After header on limit.