Alongside WhatsApp, add a chat bubble to your website or connect your mobile app directly to Cxperium — same bot, same inbox.
A Cxperium account can have more than one channel. All channels connect to the same bot, the same contact list and the same inbox; agents see the conversation on one screen regardless of where it came from.
| Type | What for | How it connects |
|---|---|---|
| Your Meta-approved business number | Our team handles the application; the approved number appears in the Channels list. | |
| Web | The chat bubble on your website | You paste the install code provided by the dashboard into your site. No coding needed. |
| Mobile (custom channel) | Your mobile app or your own client | Over REST + WebSocket; the app connects directly or through your own server. |
To create a new channel:
Go to Channels in the left menu and click Add at the top right.
Choose the Web card for chat on your website, or Mobile for a mobile app or your own client.
Enter a name for the channel and the bot address that incoming messages on this channel are forwarded to. The default language is optional. Click Add Channel.
The last step shows the channel key, the secret and (for the Web type) a ready-made install code. You can reach these later from the Detail window in the Channels list.
| Value | Example | Where it is used |
|---|---|---|
Channel key (channelKey) | ch_c727ccd3ef5112a1 | Identifies your channel in addresses and in the install code. Not secret. |
| Secret | 64-character hex | Included in the install code for the web channel. For the mobile (custom) channel it is used only at registration (app) or to sign requests (server). |
| Bot address | https://bot.example.com/api/cxperium | The chatbot endpoint every message from this channel is forwarded to. You can change it by editing the channel. |
The web channel adds a chat bubble to the corner of your site. The visitor clicks the bubble, optionally types a name and starts talking to the bot or your agent. Setup is a single snippet of code.
<!-- Cxperium chat -->
<script>
window.cxperiumSettings = {
channelKey: "ch_c727ccd3ef5112a1",
secret: "f57b4e…",
brand: "Acme Support",
color: "#7c3aed",
launcherText: "Chat with us",
texts: { greetingMessage: "Hi! How can we help you?" }
};
// Optional: introduce the user signed in on your site —
// the welcome form is skipped and the chat starts right away:
// window.cxperiumSettings.user = { name: "Jane Doe", username: "jane.d" };
(function (d) {
var s = d.createElement("script");
s.src = "https://cdn.cxperium.com/widget/v1/cxperium-widget.js";
s.async = true;
d.head.appendChild(s);
})(document);
</script>
Click Copy in the last step of channel creation or in the Channels → Detail window. If you customised the appearance, copy the code from the Widget Designer; that code contains your design.
Paste it right before the </body> tag on every page where the chat should appear. There must be only one Cxperium snippet per page; remove any old one.
The bubble should appear in the chosen corner. If not, reload without cache (Ctrl + F5).
The widget stores the visitor's identity and token in the browser; when the visitor returns, they can continue the previous conversation or open a new one. If you introduce a signed-in user through the user setting, the welcome form is never shown.
The same widget in a mobile app: you can also use the custom channel inside a WebView. The WebView Customizer screen of a mobile channel in the Channels list configures the look of the page you embed in your app, so you can add chat without writing the SDK integration below.
You change the bubble's colour, position, texts and brand from the dashboard. The designer opens only for channels of the Web type.
You reach the designer from two places: the Customize appearance button in the last step when you create the channel, or later from the channel's Detail window in the Channels list. The screen has three parts: settings on the left, a live preview on the right (Chat / Welcome form views) and the install code under the preview. There is no save button; every change is reflected in both the preview and the code within about half a second.
| Setting | Code key | What it does |
|---|---|---|
| Brand name | brand | The name shown in the window title and above bot bubbles. |
| Avatar image | avatarUrl | The round logo in the header. If empty, the brand's initial is shown. Square, at least 96×96 px, full address (https://…). |
| Launcher icon | iconUrl | The icon of the opening bubble in the corner. If empty, the standard chat icon. |
| Launcher text | launcherText | A short call-to-action next to the bubble (max. 24 characters). If empty, only the icon is shown. |
| Footer text | footerText | The small line at the bottom of the window. Leave empty to hide the line. |
| Setting | Code key | What it does |
|---|---|---|
| Primary colour | color | Header, visitor bubbles, buttons and the launcher bubble. |
| Gradient | colorDark | The dark end of the header gradient. Derived from the primary colour if empty. |
| Background | background | Background of the chat area. |
| Text / Muted | textColor, mutedColor | Text colour in bot bubbles, and secondary text such as timestamps. |
| Font | font | A CSS font name (e.g. Inter, sans-serif). If empty, the visitor's system font. The font must be loaded on your site. |
| Setting | Code key | What it does |
|---|---|---|
| Position | position | right (bottom right) or left (bottom left). |
| Side / bottom offset | sideOffset, bottomOffset | Distance of the bubble from the side and the bottom of the page (0–200 px). Increase the bottom offset if your site has a fixed bottom bar. |
| Panel width / height | width, height | Size of the opened window (280–600 / 400–900 px). On phones the window fills the screen. |
| Corner radius | radius | 0 for sharp corners, 40 for very round. |
| Setting | Code key | What it does |
|---|---|---|
| Ask the visitor's name | askName | When on, the welcome form asks for a name and requires it. When off, the visitor starts as "Visitor". |
| Ask for a username | askUsername | Same rule for a username. Off by default; most sites pass it from their own session via the user setting. |
| Chat greeting message | texts.greetingMessage | The first message shown as a bot bubble when the chat starts. Not shown if empty. |
| Welcome title / text | texts.welcomeTitle, texts.welcomeMessage | Title and description above the welcome form. |
| Name field label / placeholder | texts.nameLabel, texts.namePlaceholder | Label of the name box and the grey example text inside it. |
| Start button | texts.startButton | Text of the button that submits the form. |
| File / image sending | allowMedia | Adds an attachment button to the message box. Max. 2 MB; images and PDF. |
| Message box placeholder | texts.inputPlaceholder | The grey text in the message input. |
texts object in the code); keys you leave empty keep the Turkish default.The install code contains only the settings that differ from the defaults, so it stays short. Reset to defaults in the top bar clears all settings and the draft.
The mobile channel (custom channel) connects your iOS, Android, React Native or Flutter app directly to Cxperium. The user writes a message in the app, the bot or an agent replies, and the reply lands in the app instantly. No backend required.
| What for | Address |
|---|---|
| Registration, history, delivery acknowledgement (REST) | https://provider.cxperium.com |
| Live messaging (WebSocket, socket.io) | https://ws.cxperium.com — namespace /custom |
The decision comes down to one question: will your own server sit between your app and Cxperium?
| A) Mobile app only | B) You have your own server | |
|---|---|---|
| Where the secret lives | Embedded in the app; used only at registration | On your server as an environment variable |
| Who generates the user identity | Cxperium; returned in the registration response | You do (e.g. u_42) |
| What authenticates requests | The user token obtained at registration (Authorization: Bearer) | An HMAC signature computed with the secret (X-Cxperium-Signature) |
| Is the user verified to be that person | No; sufficient for anonymous support | Yes; your server verifies the user |
If you have no backend, follow path A. The steps below describe path A; for path B only the authentication header changes (see "If you have a server: signature").
const res = await fetch("https://provider.cxperium.com/custom/ch_xxxxxxxx/register", {
method: "POST",
headers: { "apiKey": SECRET, "Content-Type": "application/json" },
body: JSON.stringify({ user: { name: "Jane" } }),
}).then((r) => r.json());
// res = { status: 200, channelKey: "ch_xxxxxxxx",
// contact: { id: "c_5d5cdeb1-…" }, token: "8abfc196…" }
save("contactId", res.contact.id); // Keychain / EncryptedSharedPreferences
save("token", res.token); // never expires, obtained once
If the app was reinstalled and you still have the stored contactId, include it in the registration body as user.id; the same token is returned and the conversation continues where it left off. The user id cannot contain the & character.
import { io } from "socket.io-client";
const socket = io("https://ws.cxperium.com/custom", {
transports: ["websocket"],
auth: { channelKey: "ch_xxxxxxxx", user: contactId, token },
});
socket.on("connect", async () => {
// ORDER MATTERS: connect first, THEN fetch history — the reverse loses messages.
// If you store messages locally, after = last seen seq; otherwise 0.
let after = stored("lastSeq") || 0;
for (;;) {
const h = await fetch(
`https://provider.cxperium.com/custom/ch_xxxxxxxx/messages?user=${contactId}&after=${after}&limit=50`,
{ headers: { Authorization: `Bearer ${token}` } },
).then((r) => r.json());
for (const m of h.messages) render(m);
if (!h.hasMore) break;
after = h.cursor; // advance from the page's cursor, not from your own counter
}
});
History returns both directions: direction: "in" is the user's own message (draw on the right), "out" is a bot or agent reply (draw on the left). seq is a per-user increasing sequence number.
// Send — id is the replay guard: on a network error resend with the SAME id
socket.emit("message.send",
{ user: { name: "Jane" }, message: { id: uniqueId(), type: "text", text: "hello" } },
(ack) => { /* ack = { status: 202, messageId: "…" } */ });
// Receive — m = { id, seq, ts, type, text, choices, media, link, form }
socket.on("message.created", (m) => {
if (seen.has(m.id)) return; // the same message can arrive twice
seen.add(m.id);
render(m);
save("lastSeq", m.seq);
socket.emit("message.ack", { seq: m.seq }, () => {}); // everything up to seq counts as delivered
});
If the WebSocket is down, the same bodies can be sent over REST: POST /custom/<KEY>/messages and POST /custom/<KEY>/ack with the Authorization: Bearer <token> header. socket.io reconnects on its own; when connect fires again, run the history fetch from Step 2 once more and any messages missed in between arrive.
The app talks to your server, and your server talks to Cxperium. The secret never reaches the app. Every request carries an X-Cxperium-Signature: t=<unix seconds>,v1=<hex> header where v1 = HMAC-SHA256(secret, "<t>.<content>"). The content is the raw JSON body for POST requests, and the string "<channelKey>:<userId>" for the WebSocket connection and the history query. A signature is valid for 300 seconds; generate a fresh one for every request.
const crypto = require("crypto");
function sign(content) {
const t = Math.floor(Date.now() / 1000);
const v1 = crypto.createHmac("sha256", CHANNEL_SECRET).update(`${t}.${content}`).digest("hex");
return `t=${t},v1=${v1}`;
}
// For WebSocket, auth must be a FUNCTION, not a static object — the signature is refreshed on reconnect
const socket = io("https://ws.cxperium.com/custom", {
transports: ["websocket"],
auth: (cb) => cb({ channelKey: CHANNEL_KEY, user: userId, signature: sign(`${CHANNEL_KEY}:${userId}`) }),
});
stringify the POST body; if the key order changes, the signature will not match. Sign exactly the text you send.
The same message body is used in the WebSocket message.created event, in the history query and in the delivery webhook. If a type you don't recognise arrives, show the text field; new types are added over time and text is always populated.
| Type | Body | Note |
|---|---|---|
text | { "type": "text", "text": "hello" } | Plain text. |
choice | { "type": "choice", "choice": { "id": "opt_yes", "title": "Yes" } } | Clicking a button offered by the bot. |
media | { "type": "media", "media": { "kind": "image", "url": "https://…", "mime": "image/jpeg", "caption": "…" } } | Image or file; the URL must be publicly reachable. |
form | { "type": "form", "form": { "token": "…", "name": "signup_form", "version": 3, "response": { … } } } | The answer to a form the bot sent; token is echoed from the incoming form. |
The location type is not supported yet; sending it returns 202 but it is not processed.
| Type | Fields | How to render |
|---|---|---|
text | text | Plain bubble. |
choices | text, choices[] (id, title) | Buttons under the text; when clicked, send a choice reply. |
media | media.kind, url, mime, caption, filename | Image, video or file card. |
link | text, link.url, link.label | A link button; clicking sends nothing to the chat, it opens the address. The address is also the last line of text; if you render the button, drop that line. |
form | text, form.screens, form.token, form.screen, form.data | See below. |
The bot can send forms to the custom channel with the same code it uses for WhatsApp Flows. The form definition arrives inside the message as form.screens; the screens are in Meta Flow JSON format (TextInput, Dropdown, RadioButtonsGroup, CheckboxGroup, DatePicker, Footer). Start with form.screen; form.data is the data of the first screen. If you cannot render forms, at least show the text field.
complete, send the filled fields as a normal message of type form; token is required.data_exchange, call POST /custom/<KEY>/flows/exchange; the response is { "version": "3.0", "screen": "…", "data": { … } }. If screen is SUCCESS, close the form and send the data.extension_message_response.params object as the answer.The most common situations and their fixes.
| Situation | Explanation / fix |
|---|---|
| I reopened the designer and my settings are gone | The draft is kept only in the same browser. The code on your site is unchanged; that is the real record. Re-enter the values from the code into the designer. |
| The change does not show on the site | Make sure you pasted the new code and removed the old one; reload without cache (Ctrl + F5). |
| The widget looks default blue | The code may have been copied from the channel detail; that code always gives the default look. The customised code is copied from the designer. |
| There is no "Customize appearance" button | The button is shown only for Web channels. Mobile channels use the WebView Customizer. |
| Avatar / icon not showing | The address must be complete (https://…) and publicly accessible. |
Console says [cxperium] channelKey ve secret zorunlu ("channelKey and secret are required") | channelKey or secret in the install code is empty. Copy the code again from the channel detail. |
| Error | Cause | Fix |
|---|---|---|
401 Invalid channel secret | The apiKey header is missing or wrong at registration | Compare with the secret in the dashboard. |
401 Invalid token / WS INVALID_TOKEN | The token is used with another identity | Each user must use their own token; re-register if needed. |
401 Invalid signature / WS INVALID_SIGNATURE | The body was re-stringified or the signature is older than 300 s | Sign the raw text; regenerate on every request. |
WS UNAUTHORIZED | Channel missing, inactive or deleted | Check the channel key and the channel's status in the dashboard. |
WS INVALID_AUTH | Neither token nor signature given | Fix the connection's auth field. |
WS AUTH_UNAVAILABLE | Temporary server issue | Wait and retry the same way. |
| WS connects at first, then keeps rejecting | On path B, auth was given as a static object | auth must be a function; the signature is refreshed on every connect. |
| Messages occasionally go missing | No history fetch (gap-fill) | Collect with after=<last seq> on connect. |
| The same message appears twice | No deduplication by id | Deduplicate messages by id. |
400 FLOW_KEY_MISSING | No bot form key defined on the channel | Enter the form key from Channels → Edit. |
502 BOT_UNREACHABLE / BOT_ERROR | The bot did not reply within 10 s or returned an error | Show "try again"; check the bot address in the dashboard. |
| The bot never replies | Channel inactive or bot down | Contact our support team with the channel name. |
Let our technical team guide you through your integration — send us your question and we'll get back to you the same day.
Dedicated technical support · Free integration consulting