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
Section titled “Before you start”- 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
Originheader: 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}.
The flow
Section titled “The flow”GET /configto read the channel’s appearance and visitor form settings.POST /sessionswith a stable visitor ID to get a session token.GET /messagesto load the conversation, if there is one.POST /messagesto send.- After the first message exists, open
GET /eventsfor live replies, and keep pollingGET /messagesas a backstop. POST /messages/readwhen the visitor has seen the replies.
Visitor identity and sessions
Section titled “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.
- 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,/sessionsand/events. Store it securely on the device. - When a call returns
401, the token has expired or is no longer valid. CallPOST /sessionsagain with the same visitor ID to get a fresh token for the same conversation.
Endpoints
Section titled “Endpoints”Get the channel configuration
Section titled “Get the channel configuration”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.
Create or resume a session
Section titled “Create or resume a 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.
Send a message
Section titled “Send a message”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.
Load history
Section titled “Load history”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. |
Receive live events
Section titled “Receive live events”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: 1000event: pingdata: {}
event: typing_ondata: {}
event: messagedata: {"externalId":"af9d2f90-6b79-4f3f-a8bc-e6d4fd6e5b11","content":"Hi! How can I help you?","sentAt":"2026-03-07T17:41:10Z","type":"human"}
event: typing_offdata: {}| 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.
typeon the stream ishumanorai; history usesagentoraifor the same messages.- Attachments on the stream have
type,url,mimeType, and optionallyname,size,previewUrl,widthandheight. This differs slightly from the history shape. - A
typing_offis 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
Section titled “Mark messages as read”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
Section titled “Errors”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. |
Rate limits
Section titled “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
Section titled “Reference implementation”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 IDconst 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 messageslet messages = await chat.history();
// Send a messageawait chat.send("Hello, I need help with my order.", { url: location.pathname, title: document.title,});
// Subscribe to replies now that the conversation existsconst close = chat.subscribe((type, data) => { if (type === "message") console.log("Reply:", data.content);});
// Poll history as a backstopconst poll = setInterval(async () => { messages = await chat.history();}, 3000);
// Cleanup// close(); clearInterval(poll);Native mobile apps
Section titled “Native mobile apps”The steps are the same as above; only storage and networking differ.
- Enable Allow requests with no Origin header on the channel, and keep Allowed Origins limited to your web domains.
- On first launch, generate a random UUID as the visitor ID and keep it in the Keychain (iOS) or encrypted storage (Android).
- Call
POST /sessionsand store the token in secure storage. - Send with
POST /messagesand load withGET /messages, withAuthorization: Bearer <token>. - For live replies, open
GET /events?token=<token>with an SSE client library, and pollGET /messagesas a fallback, for example when the app returns to the foreground. - On
401, callPOST /sessionsagain 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.
