Developer documentation
There are two ways to integrate: embed the widget with one script tag, or talk to the same AI/human backend directly over the headless API if you're building your own chat interface. Both share one knowledge base, one set of custom actions, and one inbox on your team's side, nothing is configured twice. This page covers the widget first, then the parts that apply either way, then the headless API on its own.
Quickstart
Every Commula account gets a unique widget key and a ready-to-copy embed snippet. Find yours on the Integration page of your dashboard — it already has your real widget key filled in. It looks like this:
<script src="https://YOUR-COMMULA-DOMAIN/widget.js" data-widget-key="YOUR_WIDGET_KEY" async></script>Paste it just before the closing </body> tag on any page you want the chat bubble to appear on. No npm package, no build step required.
Where does widget.js come from?
widget.js isn't hosted somewhere separate that you need to track down — it's served directly by your Commula app, at the same domain you log into your dashboard with. That's why the snippet on your Integration page already has the correct, full URL filled in (e.g. https://app.yourcommula.com/widget.js in production, or http://localhost:3100/widget.js in this local dev setup) — just copy it as-is, you don't need to substitute anything for "YOUR-COMMULA-DOMAIN".
apps/widget and is built with pnpm dev:widget, which compiles it and copies the result into apps/web/public/widget.js — that's what makes it servable from your web app's own domain.Widget script attributes
| Attribute | Required | Description |
|---|---|---|
data-widget-key | Yes | Identifies your business/tenant. Shown on your Integration page. |
data-customer-token | No | A short-lived JWT your backend signs to identify the logged-in customer viewing the page. See below. |
data-api-base | No | Overrides the API origin the widget talks to. Defaults to the origin the script itself was loaded from, so you usually don't need this. |
Identifying logged-in customers
If a visitor is already logged into your site, you can pass their identity to Commula so your support team sees who they're talking to — without ever sharing their password or session with Commula. Your backend signs a small JSON payload as a JWT using your token secret (found on your Integration page — keep it server-side only, never ship it to the browser).
The token payload:
{
"externalId": "cus_12345", // required — your own user/customer ID
"name": "Jane Doe", // optional
"email": "jane@example.com", // optional
"metadata": { "plan": "pro" } // optional, any JSON you want attached
}Example: signing the token in a Node.js backend (e.g. inside the page render for a logged-in user):
import jwt from "jsonwebtoken";
const customerToken = jwt.sign(
{
externalId: user.id,
name: user.name,
email: user.email,
},
process.env.COMMULA_TOKEN_SECRET, // your token secret, kept server-side
{ expiresIn: "1h" }
);Then render the widget script with that token on the page (server-rendered, so it's fresh per request):
<script
src="https://YOUR-COMMULA-DOMAIN/widget.js"
data-widget-key="YOUR_WIDGET_KEY"
data-customer-token="<%= customerToken %>"
async
></script>Commula verifies the token's signature against your token secret before trusting any of the identity fields. If verification fails, the widget still works — it just falls back to an anonymous visitor.
What the widget calls internally
You won't call this yourself, the widget script does it automatically, but it's useful to know what's happening under the embed snippet if you're debugging a connection issue.
POST /api/widget/session
Called by the widget the moment a visitor opens the chat. Starts a new conversation, or resumes their existing one.
// Request
{
"widgetKey": "string", // required
"customerToken": "string", // optional, signed JWT (see above)
"visitorLabel": "string" // optional, display name for anonymous visitors
}
// Response
{
"workspaceId": "string",
"conversationId": "string",
"messages": [ /* existing messages, if resuming */ ]
}Custom actions (AI function calling)
This applies whether visitors reach you through the widget or the headless API below, the AI's available actions are the same either way. A custom action gives the AI a real endpoint on your own API to call mid-conversation, an order lookup, a balance check, a ticket filer, anything your backend already knows how to do. Register it once from the Custom Actions page, and the AI decides on its own when to use it, based entirely on the description you write. No code changes on your side beyond exposing the endpoint itself.
How it works, end to end
- A visitor asks something (e.g. "what's the status of order #4821?").
- Commula sends your enabled custom actions to the model as available "tools" (the same OpenAI-compatible function-calling format most models support, relayed through OpenRouter).
- If the model decides an action is relevant, it returns the action's name plus the arguments it wants to call it with (e.g.
{ "orderId": "4821" }) — it never sees or touches your actual endpoint directly. - Commula's server calls your API for you, using the URL, method, and headers you configured, substituting the model's arguments into the path, query string, or JSON body as you've mapped each parameter.
- Your API's JSON response is handed back to the model as the tool's result.
- The model reads that result and writes the actual reply the visitor sees — in plain language, not raw JSON. It can call more than one action (or the same one again) in a single reply if it needs to, up to a small internal round limit.
Configuring an action
| Field | Description |
|---|---|
name | The function name the model calls this by. Letters, numbers, underscores only (e.g. get_order_status). |
description | The only thing the model uses to decide whether/when to call this — be specific about what it does and what kind of question it answers. |
method | GET, POST, PUT, PATCH, or DELETE. |
url | Your endpoint. Use {paramName} for path parameters, e.g. https://api.example.com/orders/{orderId}. |
headers | Static headers sent on every call — this is where your API key or auth token goes, e.g. Authorization: Bearer sk-.... |
parameters | What the model fills in. Each has a name, type (string / number / integer / boolean), whether it's required, a description (helps the model fill it in correctly), and where it goes: path, query, or body. |
The request your API receives
Path parameters are substituted directly into the URL. Query parameters are appended as a query string. Everything else (body parameters) is sent as a single JSON object, with Content-Type: application/json always set, plus whatever static headers you configured. Example — an action named get_order_status with url = https://api.example.com/orders/{orderId}, one path parameter orderId, and one query parameter region:
GET https://api.example.com/orders/4821?region=us-west
Content-Type: application/json
Authorization: Bearer sk-... # from your configured headersWhat your API should return
Any JSON body. There's no required shape — whatever fields you return are handed straight to the model, which reads them and writes the natural-language reply itself. Keep field names and values self-explanatory (or mention the format in the action's own description) since the model has no other context about your data. A non-JSON (plain text) response also works, but JSON gives the model much better structure to work with.
// Example response for the get_order_status example above
{
"orderId": "4821",
"status": "out_for_delivery",
"estimatedDelivery": "2026-09-16",
"carrier": "FedEx",
"trackingUrl": "https://fedex.com/track/..."
}The visitor never sees this JSON directly — they'd see something like:
Limits & security
- Each call to your API has a 10-second timeout; the response body is capped at 4,000 characters before being handed to the model.
- A single reply can involve at most a few rounds of tool calls before Commula stops and asks the model for a final answer regardless — a misbehaving action can't loop forever.
- Calls come from Commula's own server, not the visitor's browser — treat your action's URL like any other server-to-server webhook. Put credentials in
headers, never in the URL or query string (those can end up in logs). - Use HTTPS, and scope whatever API key you provide as narrowly as your API allows — Commula only ever sends what you've configured, but a leaked key is still a leaked key.
- Custom actions are configured per-organisation and only your own AI ever calls your own endpoints — no data is shared across Commula accounts.
Channels
Connect messaging platforms your customers already use — their conversations land in the same Inbox as your embedded widget, with the same AI/human handoff, business hours, and knowledge base behind every reply. A conversation from a connected channel shows a small badge on its avatar so agents can tell at a glance where it came from.
Telegram
- Open Telegram and message
@BotFather, then send/newbotand follow its prompts to name your bot. - BotFather replies with a bot token — copy it.
- In Commula, go to Channels, paste the token under Telegram, and click Connect.
- Message your bot on Telegram to confirm it replies — the conversation appears in your Inbox in real time.
Discord
- Open the Discord Developer Portal, click New Application, and name it.
- In the Bot section, click Reset Token and copy the token. Under Privileged Gateway Intents, enable Message Content Intent — without it the bot can't read what your customers send.
- Under OAuth2 > URL Generator, select the
botscope with View Channels and Send Messages permissions, then open the generated URL to add the bot to a server. Discord only lets someone DM a bot they share a server with, so a customer needs somewhere to have first encountered it — pick any server you control. - In Commula, go to Channels, paste the token under Discord, and click Connect.
- Send your bot a direct message on Discord to confirm it replies — the conversation appears in your Inbox in real time.
Disconnecting a channel from the Channels page stops new messages from arriving, but past conversations stay in your Inbox history.
Realtime (Socket.io) events
Both the widget and the dashboard console connect to the same Socket.io server, mounted at path /api/socket on your Commula domain.
| Event | Direction | Payload |
|---|---|---|
conversation:join | Client → Server | conversationId: string |
message:new | Client → Server | { conversationId, content, sender: "VISITOR" | "AGENT" } |
message:received | Server → Client | { id, conversationId, sender, content, createdAt } |
Headless API
Skip the embed entirely and talk to the same backend from your own interface: knowledge base, forms, business hours, custom actions, and human handoff all included, over REST, WebSocket, and webhooks instead of a rendered widget.
Credentials
Two separate credentials, distinct from your widget's own widgetKey/tokenSecret:
- API key (
pub_...) — public, safe to use from your own frontend. - API secret (
sec_...) — private, backend-only. Signs customer-identity tokens and authenticates server-to-server calls.
1. Start a conversation — POST /api/v1/sessions
Starts a new conversation, or resumes the visitor's/customer's existing open one. Call this once per chat session, then use the sessionToken it returns for everything else below — it's scoped to just that one conversation, so it's safe to hand to your own frontend even though the request that minted it wasn't.
Three ways to call it, depending on where your integration lives:
From your frontend, anonymous visitor
// POST /api/v1/sessions
{ "apiKey": "pub_..." }
// Response
{
"conversationId": "string",
"sessionToken": "string",
"messages": [] // existing messages, if resuming
}From your frontend, identified customer
Same idea as the widget's own customer-identity flow: your backend signs a JWT with your API secret, your frontend passes it along — the secret itself never reaches the browser.
// Your backend, server-side only
import jwt from "jsonwebtoken";
const customerToken = jwt.sign(
{ externalId: user.id, name: user.name, email: user.email },
process.env.COMMULA_API_SECRET,
{ expiresIn: "1h" }
);
// -> send customerToken down to your frontend// POST /api/v1/sessions, from your frontend
{ "apiKey": "pub_...", "customerToken": "<jwt from above>" }From your backend, identified customer (no separate token needed)
// POST /api/v1/sessions
{
"apiSecret": "sec_...",
"externalId": "cus_12345",
"name": "Jane Doe",
"email": "jane@example.com"
}2. Send a message — POST /api/v1/conversations/:id/messages
Sends a message as the end user. Returns immediately (202 Accepted) — the AI/agent reply, if any, arrives asynchronously over whichever of the three channels below you're using, never as the response to this call.
curl -X POST https://YOUR-COMMULA-DOMAIN/api/v1/conversations/CONVERSATION_ID/messages \
-H "Authorization: Bearer SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "content": "Where is my order?" }'3. Receive replies — pick one or more
Option A — WebSocket (real-time)
Connect to the same Socket.io server the widget uses, then authenticate with your sessionToken instead of a bare conversation ID:
import { io } from "socket.io-client";
const socket = io("https://YOUR-COMMULA-DOMAIN", { path: "/api/socket" });
socket.emit("api:conversation:join", { sessionToken });
socket.on("api:conversation:joined", () => console.log("joined"));
socket.on("api:conversation:join-error", (err) => console.error(err));
socket.on("message:received", (message) => {
// message.sender is "BOT" | "AGENT" | "VISITOR" — see the internal shape note below
});Option B — Webhooks
Configure a webhook URL in Settings → API access and Commula POSTs every AI/agent reply (and a few other events) to it as they happen — good when your own backend, not your frontend, needs to react.
// POST to your webhookUrl
{
"type": "message.created", // or "conversation.ended"
"data": { /* a PublicMessage — see schema below */ },
"timestamp": "2026-01-01T12:00:00.000Z"
}Verify the X-Commula-Signature header (sha256=<hex>) — an HMAC-SHA256 of the raw request body using your webhook signing secret (shown next to the webhook URL field in Settings):
import crypto from "crypto";
function isValidSignature(rawBody, header, secret) {
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
return crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}Option C — Polling
Simplest to integrate, not real-time. Pass after (a message id you've already seen) to fetch only what's new:
curl "https://YOUR-COMMULA-DOMAIN/api/v1/conversations/CONVERSATION_ID/messages?after=LAST_MESSAGE_ID" \
-H "Authorization: Bearer SESSION_TOKEN"4. End a conversation — POST /api/v1/conversations/:id/end
curl -X POST https://YOUR-COMMULA-DOMAIN/api/v1/conversations/CONVERSATION_ID/end \
-H "Authorization: Bearer SESSION_TOKEN"The PublicMessage schema
Every message the API returns (via sessions, messages, or a webhook) uses this shape — stable and documented independently of Commula's own internal data model, so it won't change under you if we refactor internally.
interface PublicMessage {
id: string;
conversationId: string;
role: "user" | "assistant" | "agent"; // visitor | AI | human team member
agentName?: string | null; // set when role === "agent"
content: string;
form?: { // set when the assistant asked for structured info
title?: string;
submitLabel?: string;
fields: {
label: string;
type: "text" | "email" | "tel" | "number" | "textarea" | "select";
required: boolean;
placeholder?: string;
options?: string[]; // only for type === "select"
}[];
} | null;
formSubmission?: { // set on the user message that answered a form
title?: string;
entries: { label: string; value: string }[];
} | null;
suggestEndConversation?: boolean; // the assistant offered to end the conversation
createdAt: string; // ISO 8601
}Since there's no widget rendering these for you, your own UI is responsible for rendering form as an actual form, collecting the answers, and sending them back as a plain message (e.g. "Name: Jane Doe\nEmail: jane@example.com") — Commula parses free-text answers to a form just as well as a structured submission.
Rate limits & usage
No hard rate limits today. Request counts are tracked per day and shown in Settings → API access — useful to keep an eye on as you build, since usage-based pricing for the API is planned.
Data model
- Workspace — your business/tenant. Has a
widgetKey/tokenSecretpair for the embedded widget, and a separateapiKey/apiSecretpair (pluswebhookUrl/webhookSecret) for the headless API above. - EndCustomer — a logged-in customer identified via a signed token; upserted by
externalId. - Conversation — one chat thread, linked to an
EndCustomeror anonymous. - Message — a single message, tagged
VISITOR,AGENT, orBOT. - CustomAction — a client-defined API the AI can call (see Custom actions above).
FAQ
Can I use the widget and the headless API at the same time?
Yes. They're two entry points into the same backend, not separate products, a customer identified through one is the same EndCustomer record if they show up through the other. A common setup: the widget on your marketing site, the headless API for a native mobile app.
The dashboard's embed snippet still shows a placeholder domain — why?
It shouldn't — the Integration page always fills in your actual app URL automatically. If you're seeing a literal placeholder like your-domain.com, you're looking at an outdated snippet; refresh the Integration page to get the corrected one.
Do I need to host widget.js myself?
No. It's served by your Commula app at /widget.js — just use the snippet as given.