# Web chat

URL: /docs/guides/web-chat

Add a live chat widget to your storefront that talks to the store's inbox, for guests and signed-in shoppers.

Web chat lets shoppers message the store from any page. Their messages arrive in the store's inbox in the admin, beside SMS, and staff replies come back to the widget. Web chat is part of the **Live chat** module, which comes with a plan or as an add-on and is switched on in **Settings → Modules**.

Without the module, every chat route answers `404 MODULE_NOT_ENABLED`. Use that to hide the widget.

## How it fits together [#how-it-fits-together]

| Route | Purpose |
|---|---|
| `GET /v1/chat/settings` | The widget's greeting, as staff set it. |
| `POST /v1/chat/conversations` | Start a conversation. A guest gets a chat token. |
| `GET /v1/chat/conversation` | The conversation and its messages, oldest first. |
| `POST /v1/chat/messages` | Send the shopper's message. |

A guest is identified by `X-Chat-Token`, which works like the cart token: keep it in an httpOnly cookie on your domain and have your server send it. A signed-in shopper is identified by their bearer token instead, and needs no chat token.

The examples run on your server with both store headers, as the starter storefronts do. The chat routes also accept calls straight from the browser on one of the store's registered websites, with `X-Tenant-Key` only, as long as the store's **Browser calls** switch is on. Never send the server secret from a browser.

## Show the greeting [#show-the-greeting]

```bash
curl https://api.elaraone.app/api/v1/chat/settings \
  -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \
  -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET"
```

```json title="Response"
{ "data": { "greeting": "Hello. Ask us anything about Hearth & Oak and we will reply here." } }
```

## Start a conversation [#start-a-conversation]

Create the conversation when the shopper sends their first message, not when the widget opens, so empty conversations do not fill the inbox.

```bash tab="cURL"
curl -X POST https://api.elaraone.app/api/v1/chat/conversations \
  -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \
  -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET"
```

```ts tab="TypeScript"
const res = await fetch(`${ENGINE}/chat/conversations`, {
  method: 'POST',
  headers: { ...engineHeaders(), ...(customerToken ? { Authorization: `Bearer ${customerToken}` } : {}) },
})
const { data } = await res.json()
if (data.chat_token) {
  jar.set('chat', data.chat_token, { httpOnly: true, secure: true, sameSite: 'lax', path: '/' })
}
```

The answer is `201`:

```json title="Response (guest)"
{
  "data": {
    "chat_token": "01JB2X7K9Q4M6N8P0R2T4V6W8Y",
    "conversation": {
      "id": 77,
      "channel": "web",
      "status": "open",
      "last_message_at": "2026-10-24T09:41:00+00:00",
      "created_at": "2026-10-24T09:41:00+00:00"
    }
  }
}
```

For a signed-in shopper (bearer token sent), `chat_token` is `null` and the conversation belongs to their account straight away.

**Signing in mid-chat.** When a guest signs in, call `POST /v1/chat/conversations` again with both the bearer token and their `X-Chat-Token`. The guest conversation is moved to their account instead of a second one being opened. From then on, use the bearer token.

## Send a message [#send-a-message]

```bash tab="cURL"
curl -X POST https://api.elaraone.app/api/v1/chat/messages \
  -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \
  -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \
  -H "X-Chat-Token: $CHAT_TOKEN" \
  -H "Idempotency-Key: 7d1c2b3a-4e5f-4a6b-9c8d-0e1f2a3b4c5d" \
  -H "Content-Type: application/json" \
  -d '{ "body": "Does the oak desk lamp take an LED bulb?" }'
```

```ts tab="TypeScript"
await fetch(`${ENGINE}/chat/messages`, {
  method: 'POST',
  headers: {
    ...engineHeaders(),
    'X-Chat-Token': chatToken,
    'Idempotency-Key': messageKey, // one per message, reused on retry
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ body: text }),
})
```

`body` is plain text, up to 4,096 characters. The `Idempotency-Key` means a retry after a lost response never sends the message twice. The answer is `201` with the message:

```json title="Response"
{
  "data": {
    "id": 1203,
    "direction": "inbound",
    "body": "Does the oak desk lamp take an LED bulb?",
    "media": null,
    "delivered_at": null,
    "read_at": null,
    "created_at": "2026-10-24T09:41:05+00:00"
  }
}
```

## Show the conversation [#show-the-conversation]

`GET /v1/chat/conversation` returns the messages oldest first, paginated (`perPage` default 24, maximum 100), with the conversation in `meta.conversation`. `direction` is `inbound` for the shopper's messages and `outbound` for the store's replies.

```json title="Response (trimmed)"
{
  "data": [
    { "id": 1203, "direction": "inbound", "body": "Does the oak desk lamp take an LED bulb?", "created_at": "2026-10-24T09:41:05+00:00" },
    { "id": 1204, "direction": "outbound", "body": "It does: any E27 bulb up to 8 W.", "created_at": "2026-10-24T09:43:30+00:00" }
  ],
  "links": { "first": "…", "last": "…", "prev": null, "next": null },
  "meta": {
    "current_page": 1,
    "last_page": 1,
    "per_page": 24,
    "total": 2,
    "conversation": { "id": 77, "channel": "web", "status": "open", "last_message_at": "2026-10-24T09:43:30+00:00", "created_at": "2026-10-24T09:41:00+00:00" }
  }
}
```

Because the newest messages are on the last page, read `meta.last_page` and fetch that page to show the latest messages.

An unknown, expired or someone else's chat token gets `404 CONVERSATION_NOT_FOUND`. Clear the cookie and start a new conversation when the shopper next writes.

## Getting replies: polling [#getting-replies-polling]

There is no push channel for web chat today. The widget fetches `GET /v1/chat/conversation` on a timer to see new replies. A sensible pattern:

- poll every few seconds while the widget is open and the tab is visible;
- slow down or stop when the tab is hidden or the widget is closed;
- compare message ids to append only what is new.

The chat routes share a budget of 60 requests a minute per conversation (per chat token, signed-in shopper or IP), sends included. Polling every 5 seconds uses 12 of them. A `429 RATE_LIMITED` comes with `Retry-After`.

## In the store's inbox [#in-the-stores-inbox]

Every conversation appears in the store's inbox, where staff read and reply, with templates if they like.

- **Signed-in shoppers:** their messages are recorded on their CRM contact. A shopper the CRM has not met before becomes a contact at the Lead stage and opens a lead, which is routed like any other.
- **Guests:** the conversation has no contact until the shopper signs in and the conversation is moved to their account.
- **Flows:** each message can start flows with the **Message received** trigger (channel `web`), and the **No reply** trigger can follow up when a conversation goes quiet. See [Flows](/docs/guides/flows).
