> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Web widget: embed and anonymous visitor API

> Embed the Orbit web chat widget on your site, understand the anonymous visitor session round trip the hosted bundle performs, and verify the inbound webhooks visitor conversations trigger on your server.

# Web widget

The hosted web chat bundle makes its own API calls from the visitor's browser: it bootstraps an anonymous visitor session, loads your widget configuration, and streams the conversation over that session. You write code in exactly two places against this surface: the embed snippet on your site, and the webhook endpoint that receives visitor conversation events. This page documents both, plus the curl calls to prove the round trip from your own terminal.

All widget calls go to the public API host, `https://api.orbit.devotel.io`, on paths under `/widget/` (no `/api/v1` prefix).

## What the widget actually calls

| Call | Made by | Purpose |
| - | - | - |
| `POST /widget/session` | The bundle, from the visitor's browser | Anonymous bootstrap: exchanges your public widget id for a visitor id, a 30-minute session token, and the widget config |
| `Authorization: Bearer <session_token>` on subsequent widget calls | The bundle | Sends the visitor's messages and reads the conversation using the token from bootstrap |
| `POST` to your webhook endpoint | Orbit | Delivers `message.received` (and the rest of the standard event envelope) for the visitor conversation, signed with your webhook secret |

Bootstrap is the only unauthenticated call. When a session token expires or is rejected, the call returns `401` with `WWW-Authenticate: Bearer error="invalid_token"`, and the bundle re-bootstraps with a fresh `POST /widget/session`.

Your server never needs the widget's browser-side calls. The single contract you code against is the inbound webhook, covered in [Verify the inbound webhook on your server](#verify-the-inbound-webhook-on-your-server).

## Anonymous visitor registration round trip

The bundle posts your public widget id (copy it from **Settings → Native Chat**) to bootstrap a visitor. Reproduce the exact call with curl:

```bash curl theme={null}
curl -i -X POST https://api.orbit.devotel.io/widget/session \
  -H "Content-Type: application/json" \
  -d '{
    "widget_id": "<your-widget-id>"
  }'
```

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "visitor_id": "vis_01J9KQXGYY2B7C8D9E0F1G2H",
      "session_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
      "config": {
        "brand_color": "#0E7C66",
        "position": "bottom-right",
        "agent_name": "Ava",
        "agent_avatar_url": "https://cdn.example.com/agents/ava.png",
        "consent_required": true
      }
    },
    "meta": {
      "request_id": "req_xyz789",
      "timestamp": "2026-10-13T09:14:03Z"
    }
  }
  ```
</ResponseExample>

Three rules for handling this response:

* **`session_token` is a visitor credential, not a server credential.** It lives 30 minutes, is scoped to one visitor session, and belongs in the visitor's browser only. Do not store it server-side or reuse it across visitors.
* **`config` is what renders the launcher.** Brand color, position, agent name and avatar, business hours, and the consent prompt all come from this object, exactly as set under **Settings → Native Chat**.
* **Bootstrap is origin-checked.** The `Origin` header of the request must match one of your allowed origins, or the call returns `403`. See [Embed the widget](#embed-the-widget).

## Embed the widget

Copy the snippet from **Settings → Native Chat** and paste it before the closing `</body>` tag of every page that should show the launcher. The snippet looks like this:

```html theme={null}
<!-- Copy the exact snippet from Settings → Native Chat -->
<script
  async
  src="https://api.orbit.devotel.io/widget/embed.js"
  data-widget-id="<your-widget-id>"
></script>
```

The snippet binds the widget to your widget id; your allowed-origins list binds it to your domains. Bootstrap requests carrying an `Origin` you have not listed are refused, so a leaked snippet on someone else's site cannot mint visitor sessions against your account.

Manage the binding from **Settings → Native Chat**:

| Allowed-origins entry | Effect |
| - | - |
| `https://app.example.com` | Only that exact origin embeds the widget and bootstraps visitor sessions |
| One entry per environment | Add staging and preview domains separately; they are not inherited from production |
| Allow any origin | Any site that knows your widget id can embed it. Reserve this for deliberately public surfaces, not customer sites |

Style, greeting, business hours, and the consent prompt are configured on the same page and reach the visitor through the `config` object above; styling and channel routing are walked through in [Native chat widget channel config](/guides/native-chat-widget-channel-config).

## Test the round trip with curl

Assert the happy path and the origin gate before shipping an embed change:

```bash curl theme={null}
# 1. Bootstrap succeeds from an allowed origin and returns a usable token.
curl -s -X POST https://api.orbit.devotel.io/widget/session \
  -H "Content-Type: application/json" \
  -H "Origin: https://app.example.com" \
  -d '{ "widget_id": "<your-widget-id>" }' \
  | jq -r '.data.session_token'
# eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

# 2. The same call from an origin you have not listed is refused.
curl -i -X POST https://api.orbit.devotel.io/widget/session \
  -H "Content-Type: application/json" \
  -H "Origin: https://not-listed.example.com" \
  -d '{ "widget_id": "<your-widget-id>" }'
# HTTP/2 403
```

A `403` here means the origin gate is doing its job, not that the widget is broken. Run both calls from CI against your staging origin whenever the embed snippet or the origin list changes.

## Verify the inbound webhook on your server

When a visitor sends a message, Orbit POSTs the standard event envelope to your registered webhook endpoint, signed with your webhook secret. Verify the signature header before trusting the body, and dedupe on the event id header so a retried delivery never double-processes. The full verifier and retry semantics are in [Build your first webhook receiver](/webhooks/first-receiver); the widget-specific receiver is:

<CodeGroup>
  ```typescript Node.js theme={null}
  import crypto from "node:crypto";
  import express from "express";

  const app = express();
  // Keep the raw body: the HMAC is computed over the exact bytes Orbit signed.
  app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));

  const seen = new Set(); // back with Redis or your store in production

  app.post("/webhooks/orbit", (req, res) => {
    const signature = req.headers["x-orbit-webhook-signature"];
    const webhookId = req.headers["x-webhook-id"];

    const expected = crypto
      .createHmac("sha256", process.env.ORBIT_WEBHOOK_SECRET)
      .update(req.rawBody)
      .digest("hex");

    if (!signature || signature !== expected) {
      return res.sendStatus(401); // reject unsigned or tampered deliveries
    }
    if (seen.has(webhookId)) {
      return res.sendStatus(200); // retry of an already-processed delivery
    }
    seen.add(webhookId);

    const event = req.body;
    if (event.event === "message.received" && event.channel === "web_chat") {
      saveInbound({
        visitor: event.message.from,   // the visitor id from the session bootstrap
        text: event.message.text ?? "",
      });
    }

    res.sendStatus(200);
  });
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import os

  from flask import Flask, request

  app = Flask(__name__)
  seen = set()  # back with Redis or your store in production


  @app.post("/webhooks/orbit")
  def handle_webhook():
      signature = request.headers.get("X-Orbit-Webhook-Signature")
      webhook_id = request.headers.get("X-Webhook-Id")

      expected = hmac.new(
          os.environ["ORBIT_WEBHOOK_SECRET"].encode(),
          request.get_data(),  # exact bytes Orbit signed
          hashlib.sha256,
      ).hexdigest()

      if not signature or not hmac.compare_digest(signature, expected):
          return "", 401  # reject unsigned or tampered deliveries
      if webhook_id in seen:
          return "", 200  # retry of an already-processed delivery
      seen.add(webhook_id)

      event = request.get_json() or {}
      if event.get("event") == "message.received" and event.get("channel") == "web_chat":
          message = event.get("message") or {}
          save_inbound(visitor=message.get("from"), text=message.get("text", ""))

      return "", 200
  ```
</CodeGroup>

Reply `200` quickly and queue the work; the same delivery semantics and retry schedule as every other Orbit webhook apply.

## Troubleshooting

| Symptom | Cause | Fix |
| - | - | - |
| `403` on `POST /widget/session` | The request's `Origin` is not in your allowed origins. | Add the exact origin (scheme and host) under **Settings → Native Chat**. Staging and preview domains need their own entries. |
| `403` with code `WIDGET_DISABLED` | The widget is disabled for your organization. | Re-enable it from **Settings → Native Chat**; your configuration is kept while disabled. |
| `404` with code `WIDGET_NOT_FOUND` | The widget id is unknown or the tenant is inactive. | Re-copy the snippet and id from **Settings → Native Chat**. |
| `429` on bootstrap | The unauthenticated bootstrap limit (10 requests per minute per IP) was hit. | Honour `Retry-After`. A spike usually means the snippet re-initialises on every page render; load it once per page load. |
| `429` on visitor calls | The per-session limit (60 requests per minute) was hit for one visitor session. | Treat it as abusive input from that session; other visitors are unaffected. |
| `401` with `error="invalid_token"` | The session token expired (30 minutes) or was not minted for this widget. | The hosted bundle re-bootstraps on its own; if you drive the API yourself, re-run `POST /widget/session`. |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.