# Build a custom chat client

> Use the website chat HTTP API directly to build your own chat UI, a native mobile app or a headless integration.

Source: https://docs.message24.net/developers/website-chat/custom-chat-client/

The widget is a client of a small public HTTP API. You can call the same API to build your own chat interface, put chat inside a native iOS or Android app, or connect a webview that cannot run the widget. Messages still arrive in the business's Message24 inbox as Web chat conversations, and the AI agent and team reply the same way.

## Before you start

- **Channel key.** Create a Web Chat channel in Message24 and copy its key. See [the channel key](/developers/website-chat/overview/#the-channel-key).
- **Allowed origins.** Browser clients must run on an origin in the channel's Allowed Origins list, or the list must be empty. Native apps usually send no `Origin` header: check **Allow requests with no Origin header (native apps / strict webviews)** in the channel's settings for a channel that serves them. See [allowed origins](/developers/website-chat/overview/#allowed-origins).
- **Base URL.** All endpoints live under `https://message24.net/api/widget/{channelKey}`.

## The flow

1. `GET /config` to read the channel's appearance and visitor form settings.
2. `POST /sessions` with a stable visitor ID to get a session token.
3. `GET /messages` to load the conversation, if there is one.
4. `POST /messages` to send.
5. After the first message exists, open `GET /events` for live replies, and keep polling `GET /messages` as a backstop.
6. `POST /messages/read` when the visitor has seen the replies.

## Visitor identity and sessions

The **visitor ID** is how Message24 recognizes a returning visitor. There is exactly one session per channel and visitor ID, and one conversation per session. Calling `POST /sessions` again with the same visitor ID resumes the same session and conversation.

:::caution
Anyone who knows a channel key and a visitor ID can open that visitor's session and read the conversation. Generate the visitor ID as a random UUID and keep it private to the device or account. Never use an email address, phone number, or a sequential or otherwise guessable ID.
:::

- For a mobile app, generate a UUID on first launch and keep it in secure storage for the life of the install, or tie it to the signed-in account if the conversation should follow the user across devices.
- Visitor IDs are 1 to 128 characters with no control characters.
- The **session token** is a signed token valid for 24 hours. Send it as `Authorization: Bearer <token>` on every call except `/config`, `/sessions` and `/events`. Store it securely on the device.
- When a call returns `401`, the token has expired or is no longer valid. Call `POST /sessions` again with the same visitor ID to get a fresh token for the same conversation.

## Endpoints

### Get the channel configuration

`GET /api/widget/{channelKey}/config`

No authentication. Returns the channel's widget settings, described in [channel settings](/developers/website-chat/overview/#channel-settings-that-affect-the-widget):

```json
{
  "widgetConfig": {
    "primaryColor": "#0f766e",
    "position": "bottom-right",
    "launcherMotion": "none",
    "welcomeMessage": "Hi! How can we help?",
    "placeholder": "Type a message...",
    "allowedOrigins": ["https://example.com"],
    "businessName": "Acme Store",
    "businessLogo": "https://example.com/logo.png",
    "visitorFields": { "nameRequired": true, "phoneEnabled": true, "phoneRequired": false }
  }
}
```

Fields left empty in the settings are omitted. Use `visitorFields` to decide whether to ask for a name or phone before creating the session.

### Create or resume a session

`POST /api/widget/{channelKey}/sessions`

```json
{
  "visitorId": "0b6f1c1e-5d0a-4b7e-9d51-2f3c8a4e7b10",
  "visitor": {
    "name": "Jane Doe",
    "phone": "+964 750 000 0000"
  }
}
```

`visitor` is optional unless the channel requires a field. `name` is at most 120 characters and `phone` at most 40. The phone number is normalized. Empty values do not clear details given earlier.

Response:

```json
{
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "config": { "primaryColor": "#0f766e", "welcomeMessage": "Hi! How can we help?" }
}
```

`config` is the same object as `widgetConfig` above. Errors: `400` with `visitorId is required`, `name is required`, `phone is required` or `phone is invalid`.

The name and phone become the contact's details when the first message creates the conversation.

### Send a message

`POST /api/widget/{channelKey}/messages`

```http
Authorization: Bearer <token>
Content-Type: application/json
```

```json
{
  "content": "Hello",
  "pageContext": {
    "url": "/products/blue-sneakers",
    "title": "Blue Sneakers",
    "data": {
      "productId": "sku_123",
      "price": 79.99,
      "currency": "USD",
      "inStock": true
    }
  }
}
```

`content` is required, 5,000 characters at most after trimming. Visitors send text only; the API has no attachment upload. `pageContext` is optional: `url` up to 2,048 characters, `title` up to 300, `data` any JSON up to 8 KB. For a mobile app, use `url` and `title` for the screen the user is on. The keys the AI agent reads from `data` are listed in [page context](/developers/website-chat/javascript-api/#page-context).

Response:

```json
{
  "message": {
    "id": "6d6f7f8d-3f2d-4f7d-a2e4-ccf42a6e2d13",
    "content": "Hello",
    "sentAt": "2026-03-07T17:41:02Z"
  },
  "conversationId": "3a9cbbf8-7d8b-4c8b-b6e9-9ef76bc4c2b1"
}
```

The first message creates the contact and the conversation.

### Load history

`GET /api/widget/{channelKey}/messages`

```http
Authorization: Bearer <token>
```

Returns the 50 most recent messages, oldest first. There is no pagination. Before the first message, `messages` is an empty array.

```json
{
  "messages": [
    {
      "id": "6d6f7f8d-3f2d-4f7d-a2e4-ccf42a6e2d13",
      "externalId": "c1d0e7b2-41a8-4f55-9d2e-0f6b3b2e8a11",
      "content": "Do you have these in size 42?",
      "type": "visitor",
      "sentAt": "2026-03-07T17:41:02Z"
    },
    {
      "id": "a6700e91-6e8f-4a83-9f12-f4b528c97c63",
      "externalId": "af9d2f90-6b79-4f3f-a8bc-e6d4fd6e5b11",
      "content": "Yes, here they are:",
      "type": "ai",
      "sentAt": "2026-03-07T17:41:10Z",
      "blocks": [
        {
          "type": "product",
          "product": {
            "name": "Blue Sneakers",
            "price": 79.99,
            "currency": "USD",
            "imageUrl": "https://...",
            "url": "https://example.com/products/sku_123",
            "inStock": true,
            "externalId": "sku_123"
          }
        }
      ]
    }
  ]
}
```

| Field | Description |
|---|---|
| `id` | Message24's message ID. |
| `externalId` | The message's delivery ID. Event stream messages carry an `externalId` too; the widget skips a live message whose `externalId` is already in its list. |
| `content` | Text. May contain Markdown links and bare URLs. |
| `type` | `visitor` for the visitor's own messages, `ai` for the AI agent, `agent` for a team member. |
| `sentAt` | RFC 3339 timestamp. |
| `attachments` | Optional. Array of files on the message, each with `type` (for example `image`, `video`, `audio`), `mimeType`, `filename`, `size` and `url`, plus `thumbnailUrl`, `width` and `height` when known. |
| `blocks` | Optional. Structured content. Currently only `{"type": "product", "product": {...}}` cards, with the fields listed in [product fields](/developers/website-chat/javascript-api/#product-fields). Image URLs are time-limited, so fetch history again rather than caching them. |

### Receive live events

`GET /api/widget/{channelKey}/events?token=<token>`

A [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) stream. The token goes in the query string because browser `EventSource` cannot send headers. The conversation must exist first: before the first message this returns `400` with `No conversation yet`.

The server sends named events, so listen for each type. `onmessage` alone receives nothing.

```text
retry: 1000
event: ping
data: {}

event: typing_on
data: {}

event: message
data: {"externalId":"af9d2f90-6b79-4f3f-a8bc-e6d4fd6e5b11","content":"Hi! How can I help you?","sentAt":"2026-03-07T17:41:10Z","type":"human"}

event: typing_off
data: {}
```

| Event | Data |
|---|---|
| `ping` | `{}`. Sent on connect and every 15 seconds. If nothing arrives for about 40 seconds, treat the connection as dead and reconnect. |
| `message` | A business reply: `externalId`, `content`, `sentAt`, `type`, and optional `attachments` and `blocks`. |
| `typing_on` | `{}`. The business side is composing a reply. |
| `typing_off` | `{}`. |

Things to know:

- The stream carries only business replies. The visitor's own messages are not echoed back.
- `type` on the stream is `human` or `ai`; history uses `agent` or `ai` for the same messages.
- Attachments on the stream have `type`, `url`, `mimeType`, and optionally `name`, `size`, `previewUrl`, `width` and `height`. This differs slightly from the history shape.
- A `typing_off` is not guaranteed. The widget clears the indicator itself after 10 seconds, or when a reply arrives.

Do not rely on the stream alone. The widget keeps polling `GET /messages` even while the stream is connected: every 3 seconds with the stream open, and every 2.5 seconds without it (10 seconds when the tab is hidden). Treat history as the source of truth and replace your list with it on each poll. If `EventSource` is unavailable or blocked in your stack, polling alone works.

### Mark messages as read

`POST /api/widget/{channelKey}/messages/read`

```http
Authorization: Bearer <token>
```

No body. Marks the business's messages in the conversation as read. Call it when the visitor has the conversation open and a new reply arrives.

```json
{ "messages_marked_read": 2 }
```

The response key is snake_case, unlike the rest of the API.

## Errors

Errors are JSON with an `error` field:

```json
{ "error": "Invalid or expired session" }
```

| Status | Meaning |
|---|---|
| `400` | Validation failed. The message says which field. |
| `401` | Missing, expired or invalid token. Create the session again. |
| `403` | `Origin not allowed`. |
| `404` | `Channel not found`: the key is wrong, rotated, or the channel is disconnected. |
| `429` | Rate limited. Honour `Retry-After`. |

## Rate limits

Limits are counted per one-minute window.

| Endpoint | Per session | Per IP | Per channel |
|---|---|---|---|
| Create session | none | 30 | 200 |
| Send message | 10 | 30 | 500 |
| Load history and mark read (shared) | 120 | 240 | 2,000 |
| Open event stream | 30 | 60 | 500 |

Sending the same content from the same session within 5 seconds is rejected as a duplicate with `429` and `{"error": "Duplicate message detected"}`. A rate-limited request returns `429` with `Retry-After: 60`; a duplicate returns `Retry-After: 5`.

## Reference implementation

A minimal JavaScript client covering the whole flow. Use it as a starting point for a custom UI or a webview bridge.

```js
const BASE = "https://message24.net/api/widget";

class Message24Chat {
  constructor(channelKey) {
    this.channelKey = channelKey;
    this.token = null;
  }

  // 1. Create or resume a session. Pass a stable, random visitorId.
  async connect({ visitorId, visitor = {} } = {}) {
    const res = await this._post("sessions", { visitorId, visitor });
    this.visitorId = visitorId;
    this.visitor = visitor;
    this.token = res.token;
    return res.config; // welcome message, colours, visitor form settings
  }

  // 2. Send a message. Returns { message, conversationId }.
  async send(content, pageContext) {
    return this._post("messages", { content, pageContext });
  }

  // 3. Load message history (50 most recent, oldest first).
  async history() {
    const res = await this._fetch("messages", {
      headers: { Authorization: `Bearer ${this.token}` },
    });
    return res.messages; // [{ id, externalId, content, type, sentAt, attachments?, blocks? }]
  }

  // 4. Mark the business's messages as read.
  async markRead() {
    return this._post("messages/read");
  }

  // 5. Subscribe to live replies. Only works once a conversation exists.
  //    Returns a function that closes the stream.
  subscribe(onEvent) {
    const url = `${BASE}/${this.channelKey}/events?token=${encodeURIComponent(this.token)}`;
    const es = new EventSource(url);
    // Named events: addEventListener is required, onmessage won't fire.
    ["message", "typing_on", "typing_off"].forEach((type) => {
      es.addEventListener(type, (e) => onEvent(type, JSON.parse(e.data)));
    });
    return () => es.close();
  }

  async _post(path, body) {
    const headers = {};
    if (body !== undefined) headers["Content-Type"] = "application/json";
    if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
    return this._fetch(path, {
      method: "POST",
      headers,
      body: body === undefined ? undefined : JSON.stringify(body),
    });
  }

  async _fetch(path, options, retried = false) {
    const res = await fetch(`${BASE}/${this.channelKey}/${path}`, options);
    if (res.status === 401 && path !== "sessions" && !retried) {
      // Token expired: get a fresh one for the same visitor and retry once.
      await this.connect({ visitorId: this.visitorId, visitor: this.visitor });
      options.headers = { ...options.headers, Authorization: `Bearer ${this.token}` };
      return this._fetch(path, options, true);
    }
    if (!res.ok) {
      const payload = await res.json().catch(() => ({}));
      throw new Error(`${res.status}: ${payload.error || res.statusText}`);
    }
    return res.json();
  }
}

// --- Usage ---
const chat = new Message24Chat("YOUR_CHANNEL_KEY");

// Generate or retrieve a stable, random visitor ID
const visitorId = localStorage.getItem("my_m24_vid") ?? crypto.randomUUID();
localStorage.setItem("my_m24_vid", visitorId);

const config = await chat.connect({ visitorId, visitor: { name: "Alice" } });
console.log("Welcome:", config.welcomeMessage);

// Load previous messages
let messages = await chat.history();

// Send a message
await chat.send("Hello, I need help with my order.", {
  url: location.pathname,
  title: document.title,
});

// Subscribe to replies now that the conversation exists
const close = chat.subscribe((type, data) => {
  if (type === "message") console.log("Reply:", data.content);
});

// Poll history as a backstop
const poll = setInterval(async () => {
  messages = await chat.history();
}, 3000);

// Cleanup
// close(); clearInterval(poll);
```

## Native mobile apps

The steps are the same as above; only storage and networking differ.

1. Enable **Allow requests with no Origin header** on the channel, and keep Allowed Origins limited to your web domains.
2. On first launch, generate a random UUID as the visitor ID and keep it in the Keychain (iOS) or encrypted storage (Android).
3. Call `POST /sessions` and store the token in secure storage.
4. Send with `POST /messages` and load with `GET /messages`, with `Authorization: Bearer <token>`.
5. For live replies, open `GET /events?token=<token>` with an SSE client library, and poll `GET /messages` as a fallback, for example when the app returns to the foreground.
6. On `401`, call `POST /sessions` again with the same visitor ID.

Security notes:

- Never ship a Message24 account token or other secret in the app. The channel key is the only identifier the API needs.
- If a channel key leaks into a place it should not be, an admin can rotate it with **Regenerate key**, which breaks every existing client until it is updated.
