openapi.yaml

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.

Send customer messages
POST JSON to a webhook URL with an API key

One request per customer message. Threads are keyed by your own conversation id.

Receive replies
JSON POSTed to your callback URL

Every assistant, agent and system reply is delivered to you, with retries.

Look under the hood
JWT-protected management API and socket

Read conversations, what the assistant understood, pending approvals, and live events.

How a message flows

1
Your system POSTs the customer's message to the inbound webhook
The gateway checks the API key, the message is normalised and published on the internal message bus with a dedupe id.
2
The conversation service persists it and decides who owns the thread
With routing mode assistant the thread starts in status bot; with direct it opens straight in a human queue.
3
The assistant answers, acts or hands off
Knowledge-base answers, order status, refunds (with a human approval when needed), or a handoff to the queue whose description fits the request.
4
Replies reach your callback URL
Assistant replies, agent replies and system lines (agent introduction, closing message) all arrive as the same JSON shape with a senderType.
The only channel you need for a custom integration is the Custom webhook channel. WhatsApp, Telegram, Messenger and SMS are configured on the Integrations page by an administrator and need no code on your side.

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.

  1. 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.
  2. Send a message.
    curl -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"}'
    The response is {"ok":true,"accepted":1}.
  3. 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"
    }
  4. 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.
  5. 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.

CredentialUsed forHow it travels
Inbound API keyYour system sending customer messages to the webhookX-Api-Key: <key> or Authorization: Bearer <key>. Set per integration; compared in constant time.
Callback secretThe platform calling your callback URLSent as X-Webhook-Secret: <secret> and Authorization: Bearer <secret> on every callback. Verify it before trusting the payload.
User JWTThe management API and the realtime socketAuthorization: Bearer <token>, obtained from POST /api/auth/login. Access tokens live 15 minutes; refresh tokens rotate on every refresh.
Roles matter on the management API. Creating or reading integrations needs an admin user. Conversations are scoped by team: an agent sees their own threads and their queues, a manager their team's, an admin everything. Use a dedicated integration user with the smallest role that works.

Send a message (inbound webhook)

POST /api/webhooks/webhook/{integrationId} API key

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

FieldTypeDescription
fromrequired*stringStable 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.
textrequiredstringThe message text. Must not be blank.
conversationIdoptionalstringYour thread key. Messages sharing it continue one conversation; without it, from is the thread key (one open conversation per customer).
nameoptionalstringDisplay name for the contact, shown to agents.
messageIdoptionalstringYour id for this message. Used for deduplication, so retries with the same id are safe. A UUID is generated when omitted.
receivedAtoptionalstring (RFC 3339)When the customer sent it. Defaults to now; invalid values are ignored.
attachmentsoptionalarrayObjects {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

StatusCodeMeaning
400VALIDATION_ERRORInvalid JSON, blank text, or neither from nor conversationId.
401WEBHOOK_UNAUTHORIZEDMissing or wrong API key.
404NOT_FOUNDUnknown integration id, or the id belongs to another channel.
409INTEGRATION_DISABLEDAn administrator switched the integration off. Do not retry until it is enabled.
429(gateway)Rate limit exceeded. Back off and retry.
503SERVICE_UNAVAILABLEThe 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 conversationId opens 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 messageId for 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.

POST <your callback URL> from the platform

Headers

HeaderValue
Content-Typeapplication/json
X-Webhook-SecretThe callback secret, when one is configured. Compare it in constant time and reject mismatches with 401.
AuthorizationBearer <callback secret>, the same value, for frameworks that expect bearer auth.

Body

FieldTypeDescription
conversationIdstringYour thread key, as sent in conversationId (or from) on the inbound side.
tostringThe customer id you sent as from.
textstringThe reply text.
messageIdstring (UUID)The platform's id for this message. Use it to deduplicate redeliveries.
senderTypestringagent (a person), bot (the assistant) or system (agent introduction, closing line, maintenance notice, a test from the Integrations page).
senderNamestringThe agent's name, "Virtual assistant", or absent for system lines.
sentAtstring (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.
Callbacks originate from the cluster's egress address and target whatever URL you configure, so the endpoint must be reachable from the platform over HTTPS with a certificate the platform trusts. Private or LAN-only URLs will not work from the hosted environment.

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.

StatusWho owns itWhat you will see
botThe virtual assistantReplies with senderType: "bot". Agents do not see the thread yet; managers and admins do.
openA queue, nobody yetThe assistant handed the thread to a service group. The customer was told; nothing arrives until a person answers.
assignedAn agentAn optional system introduction ("You are now talking to Alex from Billing") followed by agent replies.
resolvedClosedA 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}.

IntentWhat happensTypical reply
faq, billing, technicalKnowledge-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_statusLooks the order up in the order system (retries transient errors 3 times).Status and delivery information for the order.
refundLooks 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, humanImmediate 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.
unknownAsks 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

Customer (your inbound POST)
I want a refund on ORD-2001, the router never arrived.
senderType: bot
I can see order ORD-2001. A refund of R 1 299 needs a colleague's approval; I have asked for it and someone from Billing will confirm shortly.
senderType: system. You are now talking to Thandi from Billing.
senderType: agent, senderName: Thandi M.
Hi Sam, I have approved the refund. It will show on your account within five working days.
senderType: bot
Refund of R 1 299 for ORD-2001 has been processed.
senderType: system. Thanks for contacting us. This conversation is now closed.

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

POST /api/auth/login public, 20 per minute per IP
{"email": "integration@example.com", "password": "…"}

HTTP/1.1 200 OK
{"token": "eyJ…", "refreshToken": "…", "userId": "…", "email": "…", "name": "…", "role": "admin", "expiresIn": 900, "sessionId": "…"}
POST /api/auth/refresh public, 120 per minute per IP
{"refreshToken": "…"}

HTTP/1.1 200 OK
{"token": "eyJ…", "refreshToken": "<new token, store it>", "userId": "…", "role": "…", "expiresIn": 900}
  • Refresh before expiresIn seconds 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/logout with {"refreshToken", "sessionId"} ends the session cleanly.
  • GET /api/auth/me returns 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.

GET /api/integrations/channels admin

Describes every channel and its configuration fields. For the webhook channel the fields are inboundApiKey (required), callbackUrl and callbackSecret.

POST /api/integrations admin
{
  "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": "…"
}
  • routingMode is assistant or direct; direct requires a defaultGroupId (from GET /api/service-groups). A missing group answers 400 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}/test with {"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.

GET /api/conversations any role
Query parameterValues
statusactive (default: open, assigned and bot), open, assigned, bot, resolved
channele.g. webhook, whatsapp, sms, telegram, messenger
groupIdA service-group id
assigneeme, unassigned or a user id
qCase-insensitive substring over the contact name, your conversationId and the last message text
limit, offsetPaging; limit defaults to 200
HTTP/1.1 200 OK
{"items": [ { …conversation… } ], "total": 12, "limit": 200, "offset": 0}
Conversation object
FieldDescription
idConversation id (use it on every conversation endpoint).
contact{id, displayName, phone}. For webhook threads the phone is empty unless from was a number.
channel, integrationIdWhich channel and integration the thread belongs to.
externalConversationId, externalUserIdYour conversationId and from. Correlate with your own records here.
groupId, groupNameThe queue the thread sits in.
assigneeId, assigneeNameThe agent handling it, or null.
status, botStatebot | open | assigned | resolved; botState is none, new, awaiting_choice or handed_off.
subjectSet by the assistant at handoff (a one-line summary, or "Approval needed: …").
lastMessageAt, lastMessagePreview, firstResponseAt, queuedAt, resolvedAt, resolvedBy, createdAt, updatedAtTiming and audit fields.
GET /api/conversations/{id}?limit=100&before= any role
{"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
FieldDescription
id, conversationIdIds.
directioninbound (from the customer) or outbound.
senderType, senderId, senderNamecustomer, agent, bot or system, with the agent's id and name when applicable.
text, attachmentsThe content. Attachments are the array you sent (or the provider's media).
externalMessageIdYour messageId on inbound; the id your callback returned on outbound.
deliveryStatus, deliveryErrorreceived for inbound; pending, sent or failed (with the reason) for outbound.
createdAtTimestamp.
POST /api/conversations/{id}/messages agent, manager, admin
{"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.

EndpointBodyEffect
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 …/resolvenoneCloses the thread and sends the closing template to the customer.
POST …/reopennoneRestores open or assigned.
GET /api/service-groupsThe 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.

GET /api/bot/sessions/{conversationId} any role
{
  "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).

GET /api/bot/approvals?status=pending&conversationId= any role
{"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
}]}
POST /api/bot/approvals/{id}/decision agent, manager, admin
{"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.

GET /api/bot/skills any role

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

WS wss://<api host>/ws?jwt=<token> any role

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.

typeWhen
conversation.createdA new conversation opened (also for assistant-owned threads, which only managers and admins receive).
message.createdA message was stored, inbound or outbound. Carries the message and the updated conversation.
conversation.assigned, conversation.transferred, conversation.updated, conversation.resolvedStatus and ownership changes.
conversation.hiddenThe thread left your scope (a colleague took it, a transfer). Drop it from your view.
presence.changedAn 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.

CodeStatusWhere
VALIDATION_ERROR400Malformed body or missing required field.
UNAUTHORIZED / WEBHOOK_UNAUTHORIZED401Missing or invalid JWT / API key.
FORBIDDEN403The role may not do this (for example a manager creating an integration).
NOT_FOUND404Unknown id, or a conversation outside your visibility.
INTEGRATION_DISABLED, CONVERSATION_RESOLVED, ALREADY_ASSIGNED, AGENT_NOT_AVAILABLE, AGENT_NOT_TAKING409State conflicts; read the message.
SERVICE_UNAVAILABLE503Message bus unreachable. Retry with the same message id.
INTERNAL_ERROR500Unexpected. Retry later and report the correlation id.

Rate limits (per gateway instance)

RouteLimit
/api/webhooks/*600 requests per minute
/api/auth/login20 per minute per client IP
/api/auth/refresh120 per minute per client IP
Everything else60 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 messageId and answer within 15 seconds.
  • Send a stable conversationId per customer session and a unique messageId per 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 correlationId from any error response in your logs.

Troubleshooting

SymptomLikely cause
401 WEBHOOK_UNAUTHORIZEDThe key in X-Api-Key differs from the integration's inbound key, or the header is missing.
404 NOT_FOUND on the webhookWrong integration id, or the URL says a different channel than the integration.
Message accepted but no replyThe 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 bubblesYour callback answered a non-2xx status or timed out five times. The reason is in deliveryError on the message.
Two conversations for one customerThe first one was resolved, or the conversationId changed between messages.
Agent cannot see the conversationIt is still owned by the assistant (status bot), or it sits in a queue the agent is not a member of.