Integrate with the Customer Care assistant
Telco CRS is an omnichannel customer-care desk. A virtual assistant answers first, looks up orders, asks for refund approvals and hands the thread to the right human queue; agents then answer from the portal. This guide shows how to plug your own chat surface into that flow: a website chat widget, a mobile app, an IVR, a CRM or another bot.
One request per customer message. Threads are keyed by your own conversation id.
Every assistant, agent and system reply is delivered to you, with retries.
Read conversations, what the assistant understood, pending approvals, and live events.
How a message flows
senderType.Quick start
Five steps take a message from your system to the assistant and a reply back. An administrator account on the portal is needed for step 1 only.
- Create a Custom webhook integration. In the portal open Integrations, choose Custom webhook, give it a name, pick routing mode Assistant, set an Inbound API key of your choosing and, if you want replies pushed to you, a Reply callback URL and Callback secret. Save. The page shows the webhook URL (
https://api.crs.apps.cfdev.co.za/api/webhooks/webhook/<integrationId>). You can also do this over the API, see Integrations. - Send a message.
The response iscurl -X POST "https://api.crs.apps.cfdev.co.za/api/webhooks/webhook/<integrationId>" \ -H "Content-Type: application/json" \ -H "X-Api-Key: <inbound API key>" \ -d '{"from":"customer-42","name":"Sam Dlamini","conversationId":"chat-9f1","text":"Where is my order ORD-1001?","messageId":"m-1"}'{"ok":true,"accepted":1}. - Receive the reply. Within a few seconds your callback URL receives a POST like the one below. Answer with any 2xx status.
{ "conversationId": "chat-9f1", "to": "customer-42", "text": "Order ORD-1001 is out for delivery and should arrive today.", "messageId": "8c2d…", "senderType": "bot", "senderName": "Virtual assistant", "sentAt": "2026-09-13T09:12:41Z" } - Watch it in the portal. Sign in as a manager or admin and open the inbox: the thread shows with a Bot badge while the assistant owns it and appears for agents once it is handed to a queue.
- Keep the thread going. Send every further customer message with the same
conversationId. After the thread is resolved, the next message with that id starts a fresh conversation automatically.
No callback URL? Leave it blank and the integration is receive-only: replies are stored for the agents and you read them with the management API or the realtime socket.
Authentication
There are three credentials, each for one direction.
| Credential | Used for | How it travels |
|---|---|---|
| Inbound API key | Your system sending customer messages to the webhook | X-Api-Key: <key> or Authorization: Bearer <key>. Set per integration; compared in constant time. |
| Callback secret | The platform calling your callback URL | Sent as X-Webhook-Secret: <secret> and Authorization: Bearer <secret> on every callback. Verify it before trusting the payload. |
| User JWT | The management API and the realtime socket | Authorization: Bearer <token>, obtained from POST /api/auth/login. Access tokens live 15 minutes; refresh tokens rotate on every refresh. |
Send a message (inbound webhook)
One customer message per request. Bodies up to 2 MiB are accepted; the request is rate limited at the gateway (see rate limits).
Request body
| Field | Type | Description |
|---|---|---|
| fromrequired* | string | Stable id of the customer on your side (user id, MSISDN, session id). Becomes the contact's external id and the to of every reply. *Required unless conversationId is given, in which case it defaults to that value. |
| textrequired | string | The message text. Must not be blank. |
| conversationIdoptional | string | Your thread key. Messages sharing it continue one conversation; without it, from is the thread key (one open conversation per customer). |
| nameoptional | string | Display name for the contact, shown to agents. |
| messageIdoptional | string | Your id for this message. Used for deduplication, so retries with the same id are safe. A UUID is generated when omitted. |
| receivedAtoptional | string (RFC 3339) | When the customer sent it. Defaults to now; invalid values are ignored. |
| attachmentsoptional | array | Objects {type, url, name}. url must be reachable by agents' browsers; type is one of image, document, audio, video or file (the default). Entries without a URL are dropped. |
Response
HTTP/1.1 200 OK
{"ok": true, "accepted": 1}
Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | VALIDATION_ERROR | Invalid JSON, blank text, or neither from nor conversationId. |
| 401 | WEBHOOK_UNAUTHORIZED | Missing or wrong API key. |
| 404 | NOT_FOUND | Unknown integration id, or the id belongs to another channel. |
| 409 | INTEGRATION_DISABLED | An administrator switched the integration off. Do not retry until it is enabled. |
| 429 | (gateway) | Rate limit exceeded. Back off and retry. |
| 503 | SERVICE_UNAVAILABLE | The message bus was unreachable. Retry with the same messageId; it is safe. |
Threading and deduplication
- The live conversation for a thread is looked up by
(integrationId, conversationId). While it is in status bot, open or assigned, new messages append to it. - Once a conversation is resolved, the next message with the same
conversationIdopens a new conversation. The assistant starts fresh; the agents can still see the history on the contact. - Duplicates are dropped twice: the bus dedupes the same
messageIdfor two minutes, and the database enforces one external message id per conversation. - Messages of one thread are processed in order. Send them sequentially; do not fire the second before the first was accepted.
Receive replies (callback)
When the integration has a Reply callback URL, every outbound message on a webhook conversation is POSTed there as JSON. This includes replies typed by agents, replies the assistant generates, and system lines.
Headers
| Header | Value |
|---|---|
| Content-Type | application/json |
| X-Webhook-Secret | The callback secret, when one is configured. Compare it in constant time and reject mismatches with 401. |
| Authorization | Bearer <callback secret>, the same value, for frameworks that expect bearer auth. |
Body
| Field | Type | Description |
|---|---|---|
| conversationId | string | Your thread key, as sent in conversationId (or from) on the inbound side. |
| to | string | The customer id you sent as from. |
| text | string | The reply text. |
| messageId | string (UUID) | The platform's id for this message. Use it to deduplicate redeliveries. |
| senderType | string | agent (a person), bot (the assistant) or system (agent introduction, closing line, maintenance notice, a test from the Integrations page). |
| senderName | string | The agent's name, "Virtual assistant", or absent for system lines. |
| sentAt | string (RFC 3339) | When the message was written. |
What to answer
- Respond within 15 seconds with any 2xx status. An empty body is fine. If you return JSON with
{"id": "…"}or{"externalMessageId": "…"}, that value is stored as the message's external id and shown to agents. - 5xx or 429 is treated as transient: the delivery is retried up to 5 times with backoff (2 s, 5 s, 15 s, 30 s, 30 s), then marked failed.
- Any other 4xx is permanent: the message is marked failed at once and the agent sees the delivery error on the bubble.
- Make the handler idempotent on
messageId: a retry after a timeout can deliver the same message twice. - Do your own work asynchronously. Acknowledge first, then forward the text to the customer.
Conversation lifecycle
A conversation moves through four statuses. Your callback receives messages in every status except resolved; the status itself is visible through the management API and the socket.
| Status | Who owns it | What you will see |
|---|---|---|
| bot | The virtual assistant | Replies with senderType: "bot". Agents do not see the thread yet; managers and admins do. |
| open | A queue, nobody yet | The assistant handed the thread to a service group. The customer was told; nothing arrives until a person answers. |
| assigned | An agent | An optional system introduction ("You are now talking to Alex from Billing") followed by agent replies. |
| resolved | Closed | A closing line (system when a person resolved it, bot when the assistant did). The next inbound message opens a new conversation. |
Transfers between queues and reassignments between agents keep the same conversation; you may see a second introduction when a new agent takes over. Reopening a resolved thread from the portal continues the old conversation.
Routing modes
- Assistant (default): the assistant answers first. The optional default queue only decides where the thread sits while the assistant talks, and where it goes when no queue description matches at handoff.
- Direct: every conversation opens in the integration's default queue for a person; the assistant never engages. Use this for surfaces where a bot answer would be wrong, such as a complaints form.
What the assistant does
The assistant classifies each customer turn into an intent, then either answers from the knowledge base, acts on the order system, asks for a human approval, resolves, or hands off. The intent, entities and outcome of every turn are recorded and readable through GET /api/bot/sessions/{conversationId}.
| Intent | What happens | Typical reply |
|---|---|---|
| faq, billing, technical | Knowledge-base search; a miss retries once with the whole conversation as the query, then hands off. | An answer from the article, or a handoff to the fitting queue. |
| order_status | Looks the order up in the order system (retries transient errors 3 times). | Status and delivery information for the order. |
| refund | Looks the order up and requests a refund. Amounts at or above the approval threshold pause for a human decision. | "I have asked a colleague to approve this"; the thread is handed to Billing with an approval card. On approval the outcome arrives as a bot message. |
| cancellation, human | Immediate handoff to the queue whose description fits. | "I am connecting you to the team"; then an agent reply. |
| closing | "Thanks", "yes, that's all": the conversation is resolved. | A short goodbye. |
| unknown | Asks for clarification once or twice, then hands off. | "Could you tell me a bit more about…" |
Two switches take the assistant out of the loop
- Maintenance (Settings): every new customer receives the maintenance message and is queued straight to a person.
- No model configured: the "assistant unavailable" message is sent and the thread is queued. Rule-based triage still runs when a model is configured but slow or failing for a turn.
Queue selection reads the service-group descriptions: the customer's words are matched against each active group's name and description. An administrator who writes clear descriptions ("Billing: invoices, payments, refunds, credit notes") gets accurate handoffs without any code change on your side.
A complete exchange as it reaches your callback
Management API
Base URL https://api.crs.apps.cfdev.co.za. Every request except sign-in, refresh and the webhooks needs Authorization: Bearer <JWT>. Responses are JSON; timestamps are RFC 3339 in UTC; ids are UUIDs unless stated. The gateway adds an X-Correlation-ID header to every response (send your own to have it echoed) and quotes it in error bodies. The full request and response schemas are in openapi.yaml.
Sign in and refresh
{"email": "integration@example.com", "password": "…"}
HTTP/1.1 200 OK
{"token": "eyJ…", "refreshToken": "…", "userId": "…", "email": "…", "name": "…", "role": "admin", "expiresIn": 900, "sessionId": "…"}
{"refreshToken": "…"}
HTTP/1.1 200 OK
{"token": "eyJ…", "refreshToken": "<new token, store it>", "userId": "…", "role": "…", "expiresIn": 900}
- Refresh before
expiresInseconds elapse. Every refresh returns a new refresh token and invalidates the old one; replaying an old token more than 30 seconds later revokes all sessions of that user and is written to the audit log. POST /api/auth/logoutwith{"refreshToken", "sessionId"}ends the session cleanly.GET /api/auth/mereturns the signed-in user.
Integrations
All integration endpoints require the admin role. Secret fields come back redacted (**** plus the last four characters); sending a redacted or empty value in a PATCH leaves the stored secret unchanged.
Describes every channel and its configuration fields. For the webhook channel the fields are inboundApiKey (required), callbackUrl and callbackSecret.
{
"channel": "webhook",
"name": "Website chat",
"routingMode": "assistant",
"defaultGroupId": null,
"enabled": true,
"config": {
"inboundApiKey": "a-long-random-string",
"callbackUrl": "https://chat.example.com/crs/replies",
"callbackSecret": "another-long-random-string"
}
}
HTTP/1.1 201 Created
{
"id": "6f0e…", "channel": "webhook", "name": "Website chat",
"config": {"inboundApiKey": "****ring", "callbackUrl": "https://chat.example.com/crs/replies", "callbackSecret": "****ring"},
"enabled": true, "botEnabled": true, "routingMode": "assistant",
"defaultGroupId": null, "defaultGroupName": null,
"webhookUrl": "https://api.crs.apps.cfdev.co.za/api/webhooks/webhook/6f0e…",
"lastInboundAt": null, "lastOutboundAt": null, "createdAt": "…", "updatedAt": "…"
}
routingModeisassistantordirect; direct requires adefaultGroupId(fromGET /api/service-groups). A missing group answers400 GROUP_NOT_FOUND.- GET
/api/integrations?channel=webhook&enabled=true&q=&limit=&offset=lists them as{items, total, limit, offset}; GET/api/integrations/{id}reads one. - PATCH
/api/integrations/{id}accepts any subset of the create fields. DELETE removes the integration; existing conversations keep their history. - POST
/api/integrations/{id}/testwith{"to": "customer-42", "text": "Hello"}sends one message straight to your callback (no conversation is created) and answers{"ok": true, "externalMessageId": "…"}or{"ok": false, "error": "…"}. Use it to verify your endpoint and secret.
Conversations and messages
Visibility is enforced on every call: a conversation outside the caller's scope answers 404. Agents never see threads in status bot.
| Query parameter | Values |
|---|---|
| status | active (default: open, assigned and bot), open, assigned, bot, resolved |
| channel | e.g. webhook, whatsapp, sms, telegram, messenger |
| groupId | A service-group id |
| assignee | me, unassigned or a user id |
| q | Case-insensitive substring over the contact name, your conversationId and the last message text |
| limit, offset | Paging; limit defaults to 200 |
HTTP/1.1 200 OK
{"items": [ { …conversation… } ], "total": 12, "limit": 200, "offset": 0}
Conversation object
| Field | Description |
|---|---|
| id | Conversation id (use it on every conversation endpoint). |
| contact | {id, displayName, phone}. For webhook threads the phone is empty unless from was a number. |
| channel, integrationId | Which channel and integration the thread belongs to. |
| externalConversationId, externalUserId | Your conversationId and from. Correlate with your own records here. |
| groupId, groupName | The queue the thread sits in. |
| assigneeId, assigneeName | The agent handling it, or null. |
| status, botState | bot | open | assigned | resolved; botState is none, new, awaiting_choice or handed_off. |
| subject | Set by the assistant at handoff (a one-line summary, or "Approval needed: …"). |
| lastMessageAt, lastMessagePreview, firstResponseAt, queuedAt, resolvedAt, resolvedBy, createdAt, updatedAt | Timing and audit fields. |
{"conversation": { … }, "messages": [ { … } ], "hasMore": false, "nextBefore": ""}
Messages come oldest-first, the newest limit of them (max 500). Pass nextBefore back as before to page into older history.
Message object
| Field | Description |
|---|---|
| id, conversationId | Ids. |
| direction | inbound (from the customer) or outbound. |
| senderType, senderId, senderName | customer, agent, bot or system, with the agent's id and name when applicable. |
| text, attachments | The content. Attachments are the array you sent (or the provider's media). |
| externalMessageId | Your messageId on inbound; the id your callback returned on outbound. |
| deliveryStatus, deliveryError | received for inbound; pending, sent or failed (with the reason) for outbound. |
| createdAt | Timestamp. |
{"text": "Hi Sam, looking into it now."}
HTTP/1.1 201 Created
{"message": { … }, "conversation": { … }}
Replying as a person auto-assigns an unassigned thread to the caller and stamps the first response time. The caller must be an agent whose state allows it: 409 AGENT_NOT_AVAILABLE or 409 AGENT_NOT_TAKING otherwise, and 409 CONVERSATION_RESOLVED on a closed thread. Managers and admins are not gated by state.
| Endpoint | Body | Effect |
|---|---|---|
| POST …/assign | {"userId": "…"} (omit for "assign to me") | Takes or reassigns the thread. 409 ALREADY_ASSIGNED when an agent races a colleague; managers and admins reassign freely. |
| POST …/transfer | {"groupId": "…", "note": "…"} | Moves the thread to another queue, unassigned. |
| POST …/resolve | none | Closes the thread and sends the closing template to the customer. |
| POST …/reopen | none | Restores open or assigned. |
| GET /api/service-groups | — | The queue catalog: [{id, slug, name, description, isActive, memberCount, createdAt}]. |
Assistant sessions and approvals
These endpoints are served by the assistant service. Its error bodies are {"detail": "…"} rather than the envelope used elsewhere.
{
"conversation_id": "…", "thread_id": "…",
"intent": "refund", "entities": {"order_id": "ORD-2001", "amount": 1299},
"requires_approval": true, "draft_response": "…",
"disposition": "approval", "retries": 0, "last_error": null, "turns": 2, "updated_at": "…"
}
404 when the assistant never handled that conversation (direct routing, or a thread created before the assistant was enabled).
{"items": [{
"id": "…", "conversation_id": "…", "action": "refund",
"payload": {"order_id": "ORD-2001", "amount": 1299}, "summary": "Refund R 1 299 on ORD-2001",
"status": "pending", "requested_at": "…",
"decided_by": null, "decided_by_name": null, "decided_at": null, "note": null, "result": null
}]}
{"approved": true, "note": "Verified with logistics"}
Moves a pending approval to approved or rejected, resumes the assistant, executes the refund on approval and posts the outcome to the customer as a bot message. 409 when the approval was already decided.
The catalog of intents the assistant recognises, with examples and queue hints, plus which model is configured and knowledge-base statistics. Useful for building a help page or a pre-chat menu on your side.
Realtime socket
The same events the portal's inbox uses. The token travels as a query parameter because browsers cannot set headers on the handshake. The first frame is {"type": "hello", "at": "…"}; send {"type": "ping"} every 25 seconds to keep the connection alive, and reconnect with a fresh token before the JWT expires.
| type | When |
|---|---|
| conversation.created | A new conversation opened (also for assistant-owned threads, which only managers and admins receive). |
| message.created | A message was stored, inbound or outbound. Carries the message and the updated conversation. |
| conversation.assigned, conversation.transferred, conversation.updated, conversation.resolved | Status and ownership changes. |
| conversation.hidden | The thread left your scope (a colleague took it, a transfer). Drop it from your view. |
| presence.changed | An agent's state changed (available, busy, break…). |
{
"type": "message.created",
"conversationId": "…", "groupId": "…", "conversationStatus": "assigned", "assigneeId": "…",
"conversation": { … }, "message": { … }, "at": "2026-09-13T09:12:41Z"
}
Filter on conversation.channel == "webhook" and conversation.integrationId to keep only your own traffic. The socket is a convenience for dashboards and receive-only integrations; the callback URL remains the reliable delivery path because it is retried and its failures are visible to agents.
Errors and rate limits
Error envelope
HTTP/1.1 409 Conflict
{"error": true, "code": "CONVERSATION_RESOLVED", "message": "This conversation has been resolved", "correlationId": "…"}
Quote the correlationId (also in the X-Correlation-ID response header) when asking for support; it links your request to the platform's logs. The assistant service answers {"detail": "…"} instead. Gateway rejections (401 on a bad JWT, 429) carry Kong's own short JSON.
| Code | Status | Where |
|---|---|---|
| VALIDATION_ERROR | 400 | Malformed body or missing required field. |
| UNAUTHORIZED / WEBHOOK_UNAUTHORIZED | 401 | Missing or invalid JWT / API key. |
| FORBIDDEN | 403 | The role may not do this (for example a manager creating an integration). |
| NOT_FOUND | 404 | Unknown id, or a conversation outside your visibility. |
| INTEGRATION_DISABLED, CONVERSATION_RESOLVED, ALREADY_ASSIGNED, AGENT_NOT_AVAILABLE, AGENT_NOT_TAKING | 409 | State conflicts; read the message. |
| SERVICE_UNAVAILABLE | 503 | Message bus unreachable. Retry with the same message id. |
| INTERNAL_ERROR | 500 | Unexpected. Retry later and report the correlation id. |
Rate limits (per gateway instance)
| Route | Limit |
|---|---|
| /api/webhooks/* | 600 requests per minute |
| /api/auth/login | 20 per minute per client IP |
| /api/auth/refresh | 120 per minute per client IP |
| Everything else | 60 000 per minute, shared |
A 429 comes with RateLimit-Remaining and RateLimit-Reset headers. Spread bursts out and retry after the reset.
Code examples
Send a message and poll for the reply (curl)
API=https://api.crs.apps.cfdev.co.za
KEY=<inbound API key>
ID=<integration id>
# 1. Customer message
curl -sS -X POST "$API/api/webhooks/webhook/$ID" \
-H "Content-Type: application/json" -H "X-Api-Key: $KEY" \
-d '{"from":"customer-42","name":"Sam","conversationId":"chat-9f1","text":"Where is my order ORD-1001?","messageId":"m-1"}'
# 2. Sign in (a manager or admin sees assistant-owned threads)
TOKEN=$(curl -sS -X POST "$API/api/auth/login" -H "Content-Type: application/json" \
-d '{"email":"manager@example.com","password":"…"}' | jq -r .token)
# 3. Find the conversation and read the thread
CONV=$(curl -sS "$API/api/conversations?channel=webhook&q=chat-9f1" -H "Authorization: Bearer $TOKEN" | jq -r '.items[0].id')
curl -sS "$API/api/conversations/$CONV" -H "Authorization: Bearer $TOKEN" | jq '.messages[] | {senderType, text}'
Callback receiver (Node.js, Express)
import express from "express";
import { timingSafeEqual } from "node:crypto";
const app = express();
app.use(express.json({ limit: "1mb" }));
const SECRET = process.env.CRS_CALLBACK_SECRET;
const seen = new Set(); // replace with your store; retries can redeliver
app.post("/crs/replies", (req, res) => {
const got = req.get("X-Webhook-Secret") ?? "";
if (got.length !== SECRET.length || !timingSafeEqual(Buffer.from(got), Buffer.from(SECRET))) {
return res.status(401).end();
}
const { conversationId, to, text, messageId, senderType, senderName } = req.body;
if (seen.has(messageId)) return res.json({ id: messageId }); // idempotent
seen.add(messageId);
res.json({ id: messageId }); // acknowledge first
deliverToCustomer(to, conversationId, { text, senderType, senderName }).catch(console.error);
});
app.listen(8080);
Callback receiver (Python, FastAPI)
import hmac, os
from fastapi import FastAPI, Header, HTTPException, BackgroundTasks
from pydantic import BaseModel
SECRET = os.environ["CRS_CALLBACK_SECRET"]
app = FastAPI()
class Reply(BaseModel):
conversationId: str
to: str
text: str
messageId: str
senderType: str
senderName: str | None = None
sentAt: str
@app.post("/crs/replies")
async def replies(body: Reply, tasks: BackgroundTasks, x_webhook_secret: str = Header(default="")):
if not hmac.compare_digest(x_webhook_secret, SECRET):
raise HTTPException(401)
tasks.add_task(deliver_to_customer, body) # acknowledge, then work
return {"id": body.messageId}
Sending from a browser widget
Do not put the inbound API key in browser code. Proxy the webhook through your own backend: the browser talks to you, you add the key and forward to /api/webhooks/webhook/{integrationId}, and you push replies back to the browser (server-sent events, a socket, or polling) when your callback receives them. The API allows cross-origin calls only from the portal's own origin.
Live events (Node.js)
import WebSocket from "ws";
const ws = new WebSocket(`wss://${API_HOST}/ws?jwt=${encodeURIComponent(token)}`);
ws.on("open", () => setInterval(() => ws.send(JSON.stringify({ type: "ping" })), 25_000));
ws.on("message", (raw) => {
const ev = JSON.parse(raw);
if (ev.type === "message.created" && ev.conversation?.channel === "webhook") {
console.log(ev.conversation.externalConversationId, ev.message.senderType, ev.message.text);
}
});
Go-live checklist
- Generate long random values for the inbound API key and the callback secret; rotate them from the Integrations page (PATCH accepts a new value at any time).
- Verify the callback secret on every request and return 401 on mismatch.
- Make the callback idempotent on
messageIdand answer within 15 seconds. - Send a stable
conversationIdper customer session and a uniquemessageIdper message; retry 503 and 429 with the same ids. - Serve your callback over HTTPS with a publicly trusted certificate; the platform does not trust private certificate authorities.
- Use the Test button on the Integrations page (or
POST /api/integrations/{id}/test) before turning traffic on. - Use a dedicated user for the management API with the smallest role that works; refresh tokens rather than signing in repeatedly.
- Keep the
correlationIdfrom any error response in your logs.
Troubleshooting
| Symptom | Likely cause |
|---|---|
| 401 WEBHOOK_UNAUTHORIZED | The key in X-Api-Key differs from the integration's inbound key, or the header is missing. |
| 404 NOT_FOUND on the webhook | Wrong integration id, or the URL says a different channel than the integration. |
| Message accepted but no reply | The integration has no callback URL (receive-only), the thread is open waiting for a person, or the assistant is in maintenance. Check the thread in the portal and GET /api/bot/sessions/{id}. |
| Agents see "delivery failed" on their bubbles | Your callback answered a non-2xx status or timed out five times. The reason is in deliveryError on the message. |
| Two conversations for one customer | The first one was resolved, or the conversationId changed between messages. |
| Agent cannot see the conversation | It is still owned by the assistant (status bot), or it sits in a queue the agent is not a member of. |