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

# Allowed domains and security

> Restrict where your widget can load and how its public endpoints are scoped.

The allowed domains list controls which origins are permitted to embed a widget. The widget's data endpoints are public and unauthenticated, so the org that owns the widget is resolved from the widget id rather than from a credential. Add your production domain(s) before launch so the widget refuses to load anywhere else.

## How the allowed domains list works

Each widget stores a list of allowed origins in its `domains` field. The behavior depends on whether that list is empty.

| Domains list | Behavior                                                                     |
| ------------ | ---------------------------------------------------------------------------- |
| Empty        | The widget can be embedded on any website. Intended for development only.    |
| Non-empty    | The widget loads only on origins in the list. All other origins are blocked. |

When you add a domain in the dashboard, the input is normalized to an **origin** — protocol plus host, with no path or trailing slash. If you omit the protocol, `https://` is prepended automatically. Only `http` and `https` are accepted.

```
yourdomain.com          ->  https://yourdomain.com
http://localhost:3000   ->  http://localhost:3000
https://app.example.com/checkout  ->  https://app.example.com
```

Because matching is exact on the normalized origin, `https://example.com` and `https://www.example.com` are different entries. Add each host you embed on, including subdomains and any `http://localhost` origin you test with.

<Warning>
  Leaving the list empty allows the widget to load on any site. Add your production domain(s) before you publish.
</Warning>

## Where the checks happen

The allowed domains list is enforced in two places: the embed loader script and the widget iframe.

```
Browser on https://yourdomain.com
        │
        │  loads /public/embed/{id} loader script
        ▼
┌─────────────────────────────────────────────┐
│ Embed loader                                │
│ if domains is non-empty and                 │
│ window.location.origin not in domains       │
│   -> log error, do not create the iframe    │
└─────────────────────────────────────────────┘
        │  origin allowed
        ▼
┌─────────────────────────────────────────────┐
│ Widget iframe                               │
│ resolves targetOrigin from parentOrigin:    │
│   domains empty   -> parentOrigin or "*"     │
│   parentOrigin in domains -> parentOrigin    │
│   otherwise -> "null" (block postMessage)    │
└─────────────────────────────────────────────┘
```

### Embed loader

The loader script returned from `/public/embed/{id}` fetches the widget's `domains` list. If the list is non-empty and `window.location.origin` is not in it, the loader logs an error and returns without creating the iframe — the widget never renders. If the list is empty, the loader proceeds.

The loader also derives `parentOrigin` from the real embedding origin (`window.location.origin`) and passes it to the iframe. Any `parentOrigin` value in the incoming query string is stripped first, so a page cannot spoof which origin it claims to be.

### Widget iframe

Inside the iframe, the widget resolves a `targetOrigin` before it posts any message to the parent window:

| Condition                          | `targetOrigin` used for `postMessage` |
| ---------------------------------- | ------------------------------------- |
| `domains` is empty                 | `parentOrigin`, or `"*"` if none      |
| `parentOrigin` is in `domains`     | `parentOrigin`                        |
| `parentOrigin` is not in `domains` | `"null"` — communication is blocked   |

When the parent origin is not in the allowed list, the iframe logs an error and refuses to post resize and viewport messages to the parent. The widget's layout (resize, mobile full-screen) only works when the parent origin is allowed.

## Public widget endpoints

The widget's runtime endpoints live under the `/core/public/web-widgets/{id}` prefix and are **unauthenticated** — visitors call them directly from the browser with no `Authorization` header. Instead of a credential, the owning organization is resolved from the widget id: the service looks up the org from the widget id against a global lookup, then opens an org-scoped database client for all further work.

```
GET    /core/public/web-widgets/{id}
GET    /core/public/web-widgets/{id}/conversations
POST   /core/public/web-widgets/{id}/conversations
POST   /core/public/web-widgets/{id}/conversations/{conversation_id}/feedback
```

| Endpoint                                                                      | Purpose                                                      |
| ----------------------------------------------------------------------------- | ------------------------------------------------------------ |
| `GET /core/public/web-widgets/{id}`                                           | Return the widget config used to render the embed.           |
| `GET /core/public/web-widgets/{id}/conversations`                             | List a visitor's conversations for this widget.              |
| `POST /core/public/web-widgets/{id}/conversations`                            | Start a new conversation or resume one by `conversation_id`. |
| `POST /core/public/web-widgets/{id}/conversations/{conversation_id}/feedback` | Submit post-conversation feedback.                           |

Because these endpoints are unauthenticated, they enforce their own boundaries:

* The org is fixed by the widget id, so a request can only ever touch data in that one organization.
* A conversation passed by `conversation_id` is rejected with `403` if it does not belong to the widget.
* Feedback payloads are size-capped, restricted to the widget's configured feedback-form field ids, and accepted only once per conversation.

<Note>
  These public endpoints are separate from the authenticated management API. To create, update, or read widgets programmatically with a token, see the [Web widgets API overview](/api-reference/web-widgets/overview).
</Note>

## Visitor user ids

A visitor is not a logged-in user, so the widget generates a `guest_` user id in the browser and stores it in `localStorage`. The storage key is scoped to both the widget id and the parent origin:

```
anyreach-uid-{widgetId}-{parentOrigin}
```

This means the same browser gets a **distinct visitor id per (widget, origin)** pair. A visitor who returns to the same site keeps their id (so their prior conversations are listed), but the same browser visiting a different embedding site, or a different widget, gets a separate id. When no parent origin is available, the key falls back to `unknown`.

## Pre-launch checklist

<Steps>
  <Step title="Add your production origins">
    In the widget's **Allowed Domains** settings, add every origin you embed on — your production host, any subdomains, and your local `http://localhost` origin for testing.
  </Step>

  <Step title="Confirm the list is non-empty">
    An empty list allows embedding anywhere. The dashboard shows a warning while the list is empty.
  </Step>

  <Step title="Verify on the real domain">
    Load the widget on an allowed origin and confirm it renders. Load it on a disallowed origin and confirm the loader logs an authorization error and the widget does not appear.
  </Step>
</Steps>

## Related

<CardGroup cols={2}>
  <Card title="Installing the snippet" icon="code" href="/web-widgets/installing-the-snippet">
    Add the embed loader to your site.
  </Card>

  <Card title="Web widgets API" icon="plug" href="/api-reference/web-widgets/overview">
    Manage widgets programmatically with the authenticated API.
  </Card>
</CardGroup>
