obboOpen workspace
Menu

Integration guide

Chat widget guide

Your chatbot answers visitors on your own website. Two lines put it there; everything after that is optional.

Sections 1 to 5 are the whole job for most websites. Sections 6 and 7 are the pair that lets your chatbot answer "where is my order?" — they need a developer and about an afternoon. The rest is reference, policy, and what to do when something is wrong.

01Your chat ID

One ID identifies your chatbot. You will find it in the snippet under Website code on the Home tab of your chat page, beside the Copy button.

It identifies your chatbot and nothing else: no account, no API key, no login anywhere on your website.

Safe to publish. It sits in your page’s HTML where anyone can read it. All it can do is ask your chatbot a question — and only on the websites you have allowed.

02Put it on your website

Add both lines to any page, just before the closing body tag.

<script src="https://www.obbo.me/obbochat.js" async></script>
<div data-obbo-chat="YOUR_CHAT_ID"></div>

The script loads once per page and mounts a chat bubble in the corner. Put it in your site template and every page has it.

Allow your website first.Your chatbot answers only on the websites listed on the Name and welcome tab of your Brand Center's chat page — your Brand Center's own website to begin with. Add every other website that shows the chat, such as a staging copy. On any other website it declines politely, and your chat page shows which website tried.

Markup added after the page loads? The script looks for it once, when the page is ready. If your site inserts the chat later — a single-page app, a tag manager, a consent banner — call ObboChat.mount() afterwards.

03How it looks

Set the look in your chatbot's Appearance settings and copy the code: it writes these attributes and style values for you. Only the chat ID is required, and you can edit the rest by hand.

<script src="https://www.obbo.me/obbochat.js" async></script>
<div data-obbo-chat="YOUR_CHAT_ID"
     data-obbo-title="Acme Help"
     data-obbo-position="left"
     data-obbo-launcher="Ask us a question"
     style="--obbo-accent: #0f766e; --obbo-accent-text: #ecfdf5; --obbo-bottom: 96px"></div>
AttributeWhat it is
data-obbo-chatYour chat ID. Required — this is what selects your chatbot.
data-obbo-titleThe panel's header text, usually your company name.
data-obbo-theme"dark" for a dark panel. Default is light.
data-obbo-position"left" to dock the bubble bottom-left. Default is right.
data-obbo-launcherWords on the chat button, such as "Ask us a question". Without it the button is a round icon.
data-obbo-icon"lines" or "dots" for a chat button whose bubble has lines or dots in it. Default is a plain bubble.
data-obbo-font"site" to use your page's own font. Default is a standard system font.
styleThe look's style values: brand color, text colors, distance from the edges, corners and window size. See Custom styles below.

Changed the look? Copy the code again. The look travels in the code, so your site keeps the old one until you replace it.

Colors

The Colors table sets six colors: a background and a text color for your brand, for the chatbot's answers and for the chat window. Your brand covers everything a visitor taps or sends: the chat button, the Send button, the suggested questions and their own messages. The chat window covers its title, the typing box, links and small print, and its lines are a faint version of its text. Tap any box to open the picker. Match my website sets your brand color and lists what it found on your homepage, such as your logo's color, at the top of every picker.

Start from Light or Dark sets every color left on Automatic. Automatic text is white or black, whichever reads better on its background. Each text box says how easy it is to read, and when a color is hard to read the picker offers Automatic again.

Custom styles

Every color, size and distance in the widget reads a style value of its own. Set any of them on the chat's div or in your own stylesheet, and yours win over the ones in the code.

[data-obbo-chat] {
  --obbo-accent: #0f766e;
  --obbo-assistant-text: #1f2937;
  --obbo-font: "Inter", sans-serif;
  --obbo-radius: 0;
  --obbo-bottom: 96px;
}
Style valueWhat it changes
--obbo-accent --obbo-accent-textThe chat and Send buttons, suggested questions, visitor messages, and the text on them
--obbo-panel-bg --obbo-text --obbo-borderThe chat window's background, text, typing box, links and lines
--obbo-assistant-bg --obbo-input-bgAnswer bubbles and the typing box
--obbo-visitor-text --obbo-assistant-textText in the visitor's messages and in the chatbot's answers
--obbo-radius --obbo-launcher-radius --obbo-chip-radiusHow round the corners are: the window, the button and the answer buttons
--obbo-font --obbo-font-sizeTypeface and text size
--obbo-width --obbo-heightSize of the open chat window
--obbo-bottom --obbo-sideDistance from the bottom and the chosen side
--obbo-shadow --obbo-zShadow, and what it sits in front of

What stays fixed

The panel's own words — the greeting, the send button, the rating prompt — are English today, and its layout is ours. For your own markup end to end, build your own interface with the headless engine below: same chatbot, your HTML, your CSS.

Answers have one language. Your chatbot replies in the language of your Brand Center, whatever language a visitor writes in: your company speaking in its own words rather than a translation of them.

04Build your own interface

The bubble is optional. The same script exposes a headless engine: send questions, subscribe to the conversation state, and render it however your site does.

<script src="https://www.obbo.me/obbochat.js" async></script>
<script>
  addEventListener('load', function () {
    var chat = ObboChat.createChat({ widgetId: 'YOUR_CHAT_ID' });
    chat.subscribe(function (state) {
      // state.turns is the conversation; state.busy while answering.
    });
    chat.ask('What do you sell?');
  });
</script>
CallWhat it is
createChat({ widgetId })One engine per chatbot; returns the handle below.
ask(text)Send one visitor question. Resolves when the reply has landed in state.
subscribe(fn)Called with the conversation state now and on every change; returns an unsubscribe function.
getState()The current conversation state: turns and whether a question is in flight.
startEstimate()Begin an estimate, as tapping Get an estimate does: after the welcome, and not again while one is under way or when the chatbot gives no estimates.
destroy()Detach every listener. Call it when your framework unmounts the UI.
ObboChat.mount()Scan the page for the snippet again, when the markup arrived late.
ObboChat.startEstimate(id?)Open a chat on the page straight into an estimate: the chat whose ID you pass, or the first. Returns false when there is none.

One engine per chatbot. Everything the bubble does — the conversation so far, a photo for an estimate, a signed-in visitor — reaches you in the state you subscribe to, so your own panel gives nothing up.

05Cost estimates

Your chatbot can price a job inside the conversation, from the questions and prices you set.

If your chatbot has a cost estimator, a visitor who asks what something costs is invited to describe the job in a few words or add a photo. The photo is read against your own estimator questions — the size bucket, a count, a rough range for a measured quantity — and what it settles is shown back for the visitor to confirm or change before it counts. Nothing is priced from a photo alone: a figure is always theirs to settle, and their words outrank the picture.

What the visitor gets

An itemised range in your currency with a line for each part of the job — or, if you choose, one figure and what it is based on — and your own line under every estimate. It is an estimate and never a quote: the arithmetic is done from your prices, and anything your prices do not cover is listed as quoted separately.

What you set up

Five short steps under Pricing quotes on your chat page, and nothing in code:

  1. Scan my site — your prices, extra charges and terms are read from the pages your chatbot already knows, and the questions that change a figure are suggested for you.
  2. Price sheet — every price on one sheet. Change a cell, or download it, edit it in Excel or Google Sheets and upload it again. Prices worked out for you are marked Estimate until you check them.
  3. Your estimates — a sample job exactly as a visitor sees it. Change, remove or add any line, and choose what visitors see: every line, or one figure and what it is based on.
  4. Questions — what a visitor is asked, in your order: pick one, pick any, a number, a date, yes or no, words or a photo. Each question says what its answer does to the figure.
  5. Charges and limits — extra charges and limits, the line said under every estimate, the size of job you would rather price yourself, and the switch that turns estimates on.

The figures stay yours. Every price worked out for you is marked Estimate on your Price sheet, and each estimate under Customer conversations says how much of it came from typical rates rather than your own prices. A range you would not stand behind is visible rather than silent.

Where estimates land

Every estimate appears under Customer conversations with the answers it was built from — each marked as read from the visitor's words, from their photo or looked up — and with their contact details when they left them. You can also have each finished estimate emailed to you.

Photos are kept for 90 days and can be deleted there one at a time. Each estimate also notes what a photo showed that no question asked about.

Nothing to build. Both the bubble and the headless engine carry the estimator — your own interface gets each estimate in the conversation state — so there is nothing here to build.

A “Get an estimate” link on your site

Any button or link on a page that carries your chat can open it straight into an estimate. Paste this where you want it and style it like the rest of your site; the Get the code area of your chat page has it ready to copy.

<a href="#get-estimate" data-obbo-estimate>Get an estimate</a>

The link works from anywhere else too — another page, an email, an ad or a social post. Add #get-estimate to the end of the address of a page that carries your chat, and the chat opens into an estimate when that page loads.

Your own button. Put data-obbo-estimate on any element, or call ObboChat.startEstimate() from your own code. A visitor with an estimate already under way goes back to it rather than starting again, and a chatbot without a live estimator simply opens.

06Tell the chat who is signed in

Your server vouches for who the visitor is, and your chatbot answers with their own details in mind.

If visitors sign in to your site, the chat can answer questions about their own account — “where is my order?” — from details your site chooses to share. Turn on Signed-in visitors on your Brand Center's chat page to get your visitor key.

Your visitor key is a server secret.Your visitor key belongs on your server only. Never put it in a web page, an app, or a code repository. If it leaks, replace it on your chat page and choose to stop the old key at once.

How it fits together

WhoWhat they do
Your serverSigns a short token about the signed-in visitor with your visitor key.
Your pageHands the token to the chat, and says when the visitor signs out.
The chatChecks the signature and the expiry, then answers with that visitor's details in mind. A token that fails the check is ignored and the visitor is answered as a guest.

What the token holds

ClaimWhat it is
subRequired. Your own ID for this visitor.
nameOptional. What to call them.
accountOptional. Anything you choose to share — orders, bookings, plan — as a JSON object of up to 6 KB.
expRequired. Expiry in Unix seconds, at most an hour away. Ten minutes is plenty: the chat asks your page for a fresh token when it runs out.
iatOptional. When you issued it, in Unix seconds.

Sign it as a JWT with HS256, using your visitor key as the secret. Any JWT library does this. In Node:

// Your server (Node + Express). npm install jsonwebtoken
import jwt from 'jsonwebtoken';

app.post('/chat-token', (req, res) => {
  res.set('Cache-Control', 'no-store');
  const user = req.user; // your own sign-in
  if (!user) return res.json({ token: null });
  const token = jwt.sign(
    {
      sub: String(user.id),
      name: user.firstName,
      account: { orders: recentOrdersFor(user) }, // whatever you choose to share
    },
    OBBO_VISITOR_KEY, // from your server's secret settings
    { algorithm: 'HS256', expiresIn: '10m' },
  );
  res.json({ token });
});

In Python:

# Your server (Python + Flask). pip install PyJWT
import time
import jwt

@app.post("/chat-token")
def chat_token():
    user = current_user()  # your own sign-in
    if user is None:
        return {"token": None}
    now = int(time.time())
    claims = {
        "sub": str(user.id),
        "name": user.first_name,
        "account": {"orders": recent_orders_for(user)},  # whatever you choose to share
        "iat": now,
        "exp": now + 600,
    }
    return {"token": jwt.encode(claims, OBBO_VISITOR_KEY, algorithm="HS256")}

On your page

Give the chat a function that fetches a token from your server. The chat calls it only when a question is asked and it holds no current token — never just because a page loaded.

<script>
  addEventListener('load', function () {
    ObboChat.identify(async function () {
      const res = await fetch('/chat-token', { method: 'POST' });
      return (await res.json()).token;
    });
  });
  // When the visitor signs out: ObboChat.identify(null);
</script>

If your site signs visitors in with a cookie, one attribute does the same: the chat sends a POST to that address on your own site. Make that route accept POST only, check your own sign-in, send no CORS headers, and answer with a null token when nobody is signed in.

<script src="https://www.obbo.me/obbochat.js" async></script>
<div data-obbo-chat="YOUR_CHAT_ID" data-obbo-identity-url="/chat-token"></div>

When a visitor signs out, call ObboChat.identify(null). The chat clears their conversation at once, so the next person at the same computer starts fresh.

Checking it works

Every chat reply tells your page what became of the token: visitor.status is signed_in, anonymous or refused, and a refused token carries a reason. Your chat page in the Brand Center also shows the last accepted and refused token.

ReasonWhat it means
bad_signatureNot signed with your current visitor key (or a replaced key past its grace day).
expiredPast its exp.
not_yet_validIts iat or nbf is in the future. Check your server's clock.
too_long_livedIts exp is more than an hour away.
invalid_claimssub or exp is missing, or a claim has the wrong type or length.
too_largeThe token, or its account details, is over the size limit.
malformedNot a well-formed JWT.
unsupported_algorithmSigned with something other than HS256.
not_enabledSigned-in visitors are turned off for this chatbot.
keys_unavailableWe could not check your key just then. Nothing to fix on your side.

Try it before you ship. On the Try it tab of your chat page, choose “A signed-in customer”, type a name and the account details your website would send, and the test chat asks with a real token signed by your key — checked exactly as a visitor's is.

What we keep.The account details are sent to our AI provider to write each answer. They are not stored with your conversations or in our logs, though an answer may repeat them.

07Answer questions about an account

“Where is my order?” — answered from your own systems.

Your chat can answer questions about a visitor's own account — where their order is, whether a payment went through — by asking one endpoint on your own server. We never connect to your systems and hold no key to them: we send a description of what one question needs, you decide what to return.

  1. Turn on signed-in visitors (section 6). Your chatbot can only ask about someone your own website has named.
  2. Build one endpoint on your server. A GET returns your catalog; a POST answers one request drawn from it.
  3. On the Name and welcome tab of your chat page, open For developers, then under Account questions paste its address into Your data endpoint and press Connect endpoint — it reads what your endpoint can answer straight away.
  4. Press Turn on. From then on that panel shows how many lookups were made, how long your endpoint takes, and the last problem in plain words.

Scope every answer to the token.Scope every answer to the sub of the visitor token you verified with your own key, and ignore any identity implied by anything else in the request. That is what makes it impossible for one visitor to be shown another visitor’s data.

Describe your data

A GET to your endpoint returns your catalog: the kinds of data you can return and the fields each one has. This is why you never have to list the questions your visitors might ask — the chat matches a question against your catalog itself, when it is asked.

// GET https://your-site.com/obbo-chat/data
// Describe the data you have. You never list the questions your visitors
// might ask — the chat matches a question to one of these itself.
{
  "version": 1,
  "resources": [{
    "name": "orders",
    "title": "Orders",
    "description": "Orders on this customer's account, newest first.",
    "scope": "visitor",
    "fields": [
      {"name": "status", "type": "string", "description": "Where it stands.",
       "values": ["paid", "shipped", "delivered"]},
      {"name": "ship_date", "type": "date", "description": "When it shipped."},
      {"name": "tracking", "type": "string", "description": "Carrier tracking link."}
    ],
    "filters": [{"field": "status", "ops": ["eq"]}],
    "default_request": {"fields": ["status", "ship_date", "tracking"], "limit": 10},
    "max_limit": 25
  }]
}
FieldWhat it is
nameLowercase identifier for this kind of data. Requests name it.
titleWhat to call it in an answer.
scopevisitor — the only scope there is: everything is about the person asking.
descriptionWhat it holds, in a sentence. Questions are matched against this, so write it the way your visitors speak.
fields[]Each field: a name, a type, a short description, and its values where they are a fixed set.
filters[]Which fields may be filtered, and with which operators: eq, neq, gte, lte, contains, in.
default_requestThe fields and limit to use when the question says nothing more.
max_limitThe most items you will ever return at once.

Answer one request

A POST carries one structured request drawn from your catalog, and the visitor token your own server signed. Everything in it comes from your catalog — a request your catalog does not describe is never sent.

// POST https://your-site.com/obbo-chat/data
// "Where is my order?" becomes this. Operators are only:
// eq, neq, gte, lte, contains, in. There is no SQL and no free text.
{
  "version": 1,
  "visitor_token": "<the token YOUR server signed for this visitor>",
  "request": {
    "resource": "orders",
    "filters": [{"field": "status", "op": "eq", "value": "shipped"}],
    "fields": ["status", "ship_date", "tracking"],
    "limit": 10
  },
  "request_id": "chat_9f2c41b8"
}

Answer with that slice and nothing else. Keep the reply under 64 KB.

// Answer with that slice, and nothing else.
{
  "version": 1,
  "resource": "orders",
  "items": [
    {"status": "shipped", "ship_date": "2026-09-14",
     "tracking": "https://carrier.example/track/4471"}
  ],
  "item_count": 1,
  "truncated": false
}
FieldWhat it is
versionAlways 1.
resourceThe resource you answered about.
items[]The rows — each one carrying only the fields that were asked for. Anything you send here may be read out to the visitor.
item_countHow many rows are in items.
truncatedtrue if you held some back.

Checking a request

Two checks, in this order: that the request is signed with your visitor key, and who it is about. Both use the same key you already sign visitor tokens with — there is nothing new to install. Both calls are signed, the GET over an empty body.

HeaderWhat it is
X-Chat-Data-SignatureTimestamp and signature. t is unix seconds and v1 the hex HMAC-SHA256 below. Accept it only within five minutes of t.
X-Chat-Key-VersionWhich of your keys signed it. It changes when you replace your key, and during the grace day both versions arrive.
X-Chat-Data-Signature: t=1760000000,v1=4b1f…9c   // hex, 64 chars
X-Chat-Key-Version: 3

signature = HMAC_SHA256(
  your_visitor_key,
  "<t>.<METHOD> <path and query>." + <raw request body>
).hex()

// GET  "1760000000.GET /obbo-chat/data."            + ""
// POST "1760000000.POST /obbo-chat/data."           + the bytes that arrived
const crypto = require('crypto');
const KEY = process.env.OBBO_VISITOR_KEY;   // the key from your chat page

// 1. Is this really from the chat service? The signature covers the method,
//    the path as sent, and the RAW body — so check before you parse.
function fromObbo(req, rawBody) {
  const fields = Object.fromEntries(
    (req.get('X-Chat-Data-Signature') || '').split(',').map(p => p.trim().split('='))
  );
  const t = Number(fields.t);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const mine = crypto.createHmac('sha256', KEY)
    .update(`${t}.${req.method} ${req.originalUrl}.`)
    .update(rawBody)
    .digest('hex');
  const a = Buffer.from(mine), b = Buffer.from(fields.v1 || '');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// 2. Who is it about? From the token YOU signed — never from the body.
function visitorId(token) {
  const [head, payload, sig] = String(token || '').split('.');
  if (!head || !payload || !sig) return null;
  const mine = crypto.createHmac('sha256', KEY)
    .update(`${head}.${payload}`).digest('base64url');
  if (mine !== sig) return null;
  const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
  if (!claims.exp || Date.now() / 1000 > claims.exp) return null;
  return claims.sub;
}

// Your catalog. Signed too, over an empty body.
app.get('/obbo-chat/data', (req, res) => {
  if (!fromObbo(req, '')) return res.sendStatus(403);
  res.set('Cache-Control', 'no-store').json(CATALOG);
});

// One request. express.text keeps the raw bytes the signature covers;
// express.json would re-encode them and the check would fail.
app.post('/obbo-chat/data', express.text({ type: '*/*' }), async (req, res) => {
  if (!fromObbo(req, req.body)) return res.sendStatus(403);
  const body = JSON.parse(req.body);
  const customer = visitorId(body.visitor_token);
  if (!customer) return res.sendStatus(403);

  const asked = body.request || {};
  if (asked.resource !== 'orders') return res.sendStatus(422);   // not mine to answer

  // Scope EVERY query by `customer`. Ignore any id in the request body.
  const rows = await db.orders.findAll({
    where: { customer_id: customer, ...where(asked.filters) },
    limit: Math.min(asked.limit || 10, 25),
  });

  // Return the fields that were asked for, and never a field you did not
  // declare: everything you send is read out to the visitor.
  const items = rows.map(row => pick(row, asked.fields));
  res.set('Cache-Control', 'no-store')
     .json({ version: 1, resource: 'orders', items, item_count: items.length });
});

What to answer

Your status is what your chat page reports to you, so use these exactly:

AnswerWhat it means
200Here is the data. An empty items list is a perfectly good answer — your chatbot says it found nothing on their account.
403The signature or the visitor token did not check out. Answer the same way for every refusal; never say which check failed.
404Nothing is listening. Your chat page reads this as a wrong address.
422Your catalog describes this request but you will not serve it. Your chatbot answers without it and tells the visitor nothing is wrong.
500Something broke on your side. Counted as a failure.
301 / 302Never followed — give us the final address instead.

Five failures in a row and we stop asking. Lookups pause for five minutes and your chat page says so, with the last reason. They resume on their own; a successful lookup clears the count, while Read what it can answer only tests the endpoint. Your chatbot keeps answering everything else throughout.

Slow counts as off.A request we send is answered within 2.5 seconds or the chat answers without it — the visitor’s question is never failed because your endpoint was slow.

08Limits and timings

Everything the contract caps, in one place.

LimitWhat it applies to
2.5 sYour endpoint's budget for one request. Slower than that and your chatbot answers without it.
5 sYour endpoint's budget when your catalog is read.
8 KBThe largest request body we send.
64 KBThe most we will read from your reply.
8 KBHow much of your reply reaches the answer — whole items, dropped from the end until the rest fits.
32 KBThe largest catalog we will read.
12Kinds of data in one catalog.
40Fields in one kind of data.
10Filters in one request.
120Characters in one filter value.
10Values in an in filter.
6 KBAccount details carried in a visitor token.
1 hThe longest a visitor token may live. Ten minutes is plenty.
5Failures in a row before lookups pause.
5 minHow long they pause for.

09Content Security Policy

If your site sets a CSP, allow these two. Nothing else is needed: no frames, no fonts, no images of ours.

DirectiveWhy
script-src https://www.obbo.meLoads the widget script.
connect-src https://www.obbo.meThe chat questions and answers.

The style values in the code are applied by the widget's script, so a policy that blocks inline styles keeps your look. The browser may still log a warning about the style attribute; it is harmless.

Who is identified.Guests are never identified: a random session key in their browser groups one visitor's questions into a conversation on your dashboard, and that is all it does. Signed-in visitors are identified only by the ID and name your site sends.

10When it does not work

Nothing appears at all

Check that the div carries data-obbo-chat with your chat ID and that the script tag is on the page. If the markup arrives after the page is ready, call ObboChat.mount() once afterwards. Your chat page also names the website the chat was last seen working on, which settles whether the snippet is live at all.

It says it cannot answer on this website

The website is not on your allowed list. Add it on the Name and welcome tab of your chat page — the website that was turned away is named there, so you can check it is really yours.

It keeps saying it is not sure

Your chatbot answers from what it has read of your website and nothing else. Check the Your website tab, publish after a change, and read the Conversations tab: every question a visitor asked is there, and the ones it could not answer are the pages worth writing.

My visitor tokens are refused

Every reply says why: visitor.status is refused, with one of the reasons in section 6. The usual two are a server clock that disagrees with ours and a key replaced on one side only. Your chat page shows the last refusal and its reason.

Account questions say my endpoint could not be reached

Your chat page names the problem in plain words. If it is the signature, check three things: it covers the raw body rather than re-encoded JSON, the path includes its query string, and the method is upper case.

You have stopped asking my endpoint

Five failures in a row pause lookups for five minutes, and they start again by themselves. Fix the cause and the next lookup that works clears the count.

11Questions?

Tell us the page address, your chat ID and the question you asked, and we will look at the same thing you are looking at. We read everything sent to hello@obbo.me

About the Obbo chatbot

Chat widget guide