Skip to content

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.

Markdownllms.txt

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.

  • Channel key. Create a Web Chat channel in Message24 and copy its key. See 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.
  • Base URL. All endpoints live under https://message24.net/api/widget/{channelKey}.
  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.

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.

  • 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.

GET /api/widget/{channelKey}/config

No authentication. Returns the channel’s widget settings, described in channel settings:

{
"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.

POST /api/widget/{channelKey}/sessions

{
"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:

{
"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.

POST /api/widget/{channelKey}/messages

Authorization: Bearer <token>
Content-Type: application/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.

Response:

{
"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.

GET /api/widget/{channelKey}/messages

Authorization: Bearer <token>

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

{
"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. Image URLs are time-limited, so fetch history again rather than caching them.

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

A 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.

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.

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

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.

{ "messages_marked_read": 2 }

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

Errors are JSON with an error field:

{ "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.

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.

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

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);

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.