Skip to main content

Customer Chat API (Build Your Own Chat UI)

Build your own chat frontend on Orki's backend — your design, your components, while Orki's AI agents, human handover, knowledge base, and the operator dashboard all keep working unchanged. This is the exact same API that powers the official widget; nothing here is a second-class surface.

Shortcuts: @orki/chat-core (JS/TS — browser, React Native, Node) and the OrkiChatClient class inside orki_webchat (Dart/Flutter) wrap everything on this page. Read on if you want to call the API directly or build for another stack.

Postman playground: collection · production environment — import both, fill in tenant_id / integration_id, run Mint / resume session first (its test script captures session_token, customer_id, chat_id into variables for every other request). The collection demonstrates the anonymous flow; on a jwt-mode integration, add "identityToken": "…" to that request's body as shown in §2b below.

1. Architecture in one paragraph

Your frontend talks to one service over one base path — the public chat API:

https://app.orki.ai/services/public/ext/v1/tenants/{tenantId}/integrations/{integrationId}/...

Everything except sending a message is plain REST (session, profile, history, media upload, read receipts). Sending messages and receiving replies happens over a SignalR WebSocket hub — there is deliberately no REST send endpoint. Replies can come from an AI agent or a human support agent; your client renders both the same way.

The two production base URIs
  • https://app.orki.ai/services/public — the public chat API (this page). Customer-side, session-token auth, everything a custom chat UI needs.
  • https://app.orki.ai/services/gateway — the dashboard/management API (Keycloak-authed operator surface: agents, integrations, campaigns…). Never call it from a customer-facing frontend; it is not part of this integration.

You need a tenantId and an integrationId — create a web integration in the dashboard (Integrations → Website) and copy both from the install panel.

2. Authentication

Two layers, one simple rule: your app holds a short-lived session_token and sends it everywhere; how you obtain that token depends on the integration's auth mode.

2a. The session token (all modes)

  1. POST /session → the response carries session_token (a 30-day JWT) plus customer_id and chat_id.
  2. Persist it (localStorage / secure storage) and send it as:
    • Authorization: Bearer <session_token> on every REST call
    • ?access_token=<session_token> on the WebSocket URL
  3. To resume on a later launch, call POST /session again with the bearer — the same customer and chat come back. On 401, drop the stored token and start fresh.

Two mandatory request rules:

  • X-Public-Chat-Client: 1 header on every POST/PUT/DELETE (missing = 403 {"error":"Missing X-Public-Chat-Client header"} — CSRF guard).
  • If your HTTP stack sends an Origin header it must match the domain registered on the integration (localhost always allowed). Clients that send no Origin (native apps, server-side) are unaffected.

For integrations in jwt or both auth mode (see Authenticated Visitors), your backend signs a short identity JWT and the frontend exchanges it at bootstrap:

POST /session
Content-Type: application/json
X-Public-Chat-Client: 1

{ "identityToken": "<HS256 JWT: {sub, iat, exp≤24h, tenantId, integrationId, name?, email?}>" }

A valid signature replaces the bot check entirely — no Turnstile, and the visitor is bound to your sub (same conversation on every device, customer.identityVerified: true). Re-POST /session with a fresh identityToken any time before it expires; the session continues in place. This is the mode to use for apps behind your own login, and the only mode native mobile custom UIs can use.

401 error codes: jwt_required, jwt_invalid_signature, jwt_expired, jwt_not_yet_valid, jwt_exp_too_far, jwt_tenant_mismatch, jwt_integration_mismatch, jwt_malformed. In both mode with Enforce identity off, an invalid JWT still returns 200 with an anonymous session plus the response header X-Orki-Identity-Error: <code> — watch for it and refresh your token.

2c. Anonymous visitors — Turnstile (web frontends only)

In anonymous/both mode, a new session must pass Cloudflare Turnstile: render the Turnstile widget on your page with Orki's sitekey (ask us for it) and send the result:

{ "turnstileToken": "<token from the Turnstile callback>" }

403 = challenge failed/stale → re-render and retry. Resuming with a stored bearer never needs Turnstile. Because Turnstile is browser-only, anonymous mode is not available to native custom UIs — use the identity JWT there.

Rate limits: anonymous mints are capped at 30/IP/hour; JWT-verified bootstraps at 600/integration/hour (both 429).

The session response

Top level is snake_case (historic); everything else in the API is camelCase:

{
"customer_id": "665f0c…",
"chat_id": "665f0d…",
"unread_count": 0,
"session_token": "eyJhbGciOiJIUzI1NiIs…",
"customer": { "id": "…", "name": "Layla", "email": null, "phone": null,
"language": null, "identityVerified": true },
"chat": { "id": "…", "status": "unopened", "handler": "…",
"handlerName": "Maya", "unreadCount": 0 }
}

A fresh anonymous session creates a ghost customer and an unopened chat; the chat flips to open and the customer becomes real automatically when the first message is processed. If an anonymous visitor later logs in (you send an identityToken on the same session), their conversation is upgraded in place — history preserved.

3. Endpoint reference

All paths relative to …/tenants/{tenantId}/integrations/{integrationId} unless noted. 🔓 = anonymous · 🔑 = bearer required · ✉️ = also needs X-Public-Chat-Client: 1.

Bootstrap & profile

EndpointNotes
🔓GET ""Widget/branding config: primaryColor, starterMessages, initialForm (which profile fields to collect), aiProfileName, authMode, showPoweredBy — useful even for a custom UI (greeting texts, which pre-chat fields the tenant enabled)
🔓✉️POST /sessionMint or resume — see §2. Body fields all optional: identityToken, turnstileToken, chatName
🔑GET /customer/me{customer, chat}customer: null means "call POST /session"
🔑✉️PUT /customer/me{"name": "…", "email": "…", "phone": "…"} — omitted/null fields untouched. Use for a pre-chat form (skip it for JWT-identified users; their claims already filled the profile)

Conversation

EndpointNotes
🔑GET /chats/{chatId}{id, tenantId, status, handler, createdAt, platformId} — poll it if you want to display "you're now talking to a human" on handover
🔑GET /chats/{chatId}/messages?pageSize=50Newest-first. Response {data: Message[], hasMore, nextBefore}. Older pages: repeat with &before={nextBefore} (URL-encoded ISO timestamp)
🔑✉️POST /chats/{chatId}/read{"lastMessageId": "…"} — marks that message and everything older as read

Media

EndpointNotes
🔑✉️POST /chats/{chatId}/media/tempmultipart/form-data, field file, one file. 25 MiB max (413), virus/type-scanned (400) → {"ref", "contentType", "sizeBytes", "fileName"}
🔓GET {base}/temp-media/{tenantId}/{ref}Preview a staged upload (the unguessable ref is the auth). Not under the tenants prefix
🔑✉️DELETE /chats/{chatId}/media/temp/{ref}Un-stage. Idempotent, 204
🔑✉️POST /chats/{chatId}/media/from-temp{"refs": ["…"]}{"ids": ["…"]} — promotes staged files; the ids go into the message's mediaIds
🔑✉️POST /chats/{chatId}/mediaLegacy one-shot multipart upload → {"id"} (the widget uses it for voice notes). Prefer temp→promote
🔑GET /chats/{chatId}/message/{messageId}/mediaAttachment metadata: [{id, mimeType, name, size, width, height, numOfPages}]
🔑GET /chats/{chatId}/message/{messageId}/media/{mediaId}The bytes (range requests supported). For <img src>-style loading append ?access_token={session_token}
🔓GET /chats/{chatId}/handler/photo · GET /default-handler/photoAgent avatar (204 if none)

Sending a message with attachments — the full sequence:

1. POST …/media/temp  (once per file)      → refs
2. (optional) preview GET …/temp-media/{tenantId}/{ref}
3. POST …/media/from-temp {"refs":[…]} → ids
4. hub invoke CustomerMessage { content: { text: "…", mediaIds: ids } }

4. The SignalR hub (send + receive)

wss://app.orki.ai/services/public/ext/v1/chat/hub?tenantId={t}&integrationId={i}&access_token={session_token}

Use a SignalR client library@microsoft/signalr (JS / React Native), com.microsoft.signalr (Android), Microsoft.AspNetCore.SignalR.Client (.NET/MAUI), signalr_netcore (Flutter). Connect with skipNegotiation: true and the WebSockets transport, only after POST /session succeeded — the server aborts connections whose token doesn't map to an existing customer.

import * as signalR from "@microsoft/signalr";

const BASE = "https://app.orki.ai/services/public/ext/v1";

const connection = new signalR.HubConnectionBuilder()
.withUrl(`${BASE}/chat/hub?tenantId=${tenantId}&integrationId=${integrationId}`, {
accessTokenFactory: () => sessionToken, // appended as ?access_token=
skipNegotiation: true,
transport: signalR.HttpTransportType.WebSockets,
})
.withAutomaticReconnect()
.build();

connection.on("Message", ({ tempId, message }) => {
if (tempId) reconcileOptimisticBubble(tempId, message); // echo of your own send
else renderIncoming(message); // AI or human reply
});
connection.on("MessageEdited", ({ message }) => mergeMessage(message));
connection.on("TypingIndicator", ({ show, isCustomer }) => {
if (isCustomer === false) setAgentTyping(show); // auto-clear after ~20 s
});
connection.onreconnected(() => refetchLatestHistoryPage()); // fill any gap

await connection.start();

await connection.invoke("CustomerMessage", {
chatId,
tempId: crypto.randomUUID(), // echoed back → reconcile your optimistic bubble
content: { text: "Hello!" },
correlationId: crypto.randomUUID(),
});

You invoke:

MethodPayload
CustomerMessage{chatId, tempId, content: {text?, mediaIds?}, replyTo?, correlationId}
Typing{chatId, userId: customer_id, show: true|false} — throttle to ≤1 show:true per 10 s

You receive:

EventPayload
Message{tempId?, message} — your own echo (with your tempId) and every agent/human reply; message.isCustomer tells you which side
MessageEditedSame shape — merge status / content updates
TypingIndicator{chatId, userId, show, isCustomer}
Raw-frame appendix — driving the hub from Postman (demos / debugging)

Postman: New → WebSocket, paste the URL above, connect, then send these frames in order. Every SignalR frame must end with the invisible ASCII record separator 0x1E (shown as — don't type it literally; copy frames from a file where the byte is real, or append it programmatically).

{"protocol":"json","version":1}␞                      ← handshake; server answers {}␞
{"type":1,"target":"CustomerMessage","arguments":[{"chatId":"<CHAT_ID>","tempId":"<uuid>","content":{"text":"Hi!"},"correlationId":"<uuid>"}]}␞
{"type":1,"target":"Typing","arguments":[{"chatId":"<CHAT_ID>","userId":"<CUSTOMER_ID>","show":true}]}␞
{"type":6}␞ ← keepalive; send every ~20 s or the server drops you

Frame type values: 1 invocation (both directions), 3 completion, 6 ping, 7 server closing (the error field says why).

5. The message model (what you render)

{
"id": "665f…", // real Mongo ObjectId once stored
"chatId": "…",
"isCustomer": true, // true = the visitor; false = AI agent or human handler
"sender": "…",
"content": {
"text": "Hello!", // markdown-ish (WhatsApp flavour)
"mediaIds": ["…"], // fetch metadata + bytes via the media endpoints
"location": [lon, lat],
"carousel": [ { "header", "footer", "headerImage", "buttonTitle", "buttonLink" } ],
"choices": [ { "id", "title" } ] // tap-to-reply buttons the agent offered
},
"replyTo": { "messageId", "content", "sender", "isCustomer" },
"timestamp": "2026-08-11T09:12:33.123Z",
"status": "stored", // stored → sent → delivered → read | failed
"readBy": [ { "userId", "timestamp" } ]
}

Rendering rules that make the UX right:

  • Media messages carry only mediaIds — fetch the metadata endpoint, then render by mimeType prefix (image/video inline, audio player, document card). Only for real (24-char id) messages, not your optimistic bubbles.
  • Voice notes you send (audio upload + empty text) are transcribed server-side and the AI answers the transcription — keep showing your local audio player in the customer bubble.
  • Handover to a human needs no special handling: replies keep arriving as Message with isCustomer: false. Poll GET /chats/{chatId} if you want to badge the handler change.
  • Resolved chats reopen implicitly — just send another CustomerMessage.

Worth knowing when designing the attachment UI: images, PDFs and office documents are passed into the AI model (it genuinely reads them); voice notes are auto-transcribed; video is stored for human agents but not interpreted by the AI.

6. Integration checklist

  • Store session_token; resume via POST /session with the bearer on every launch
  • Identified users: mint the identity JWT server-side, exchange at bootstrap, refresh via a fresh POST /session before exp; handle the jwt_* 401 codes and X-Orki-Identity-Error
  • X-Public-Chat-Client: 1 on every mutating request
  • Generate a tempId (UUID) per outgoing message; reconcile on the echoed Message
  • withAutomaticReconnect() + refetch the latest history page on reconnect
  • Throttle Typing; POST /read when the newest message becomes visible
  • Cap uploads at 25 MiB client-side; surface 413 and scan-rejection 400s
  • Anonymous web UIs: render Turnstile for new sessions; native UIs: JWT identity only