# Channel API (Coming soon)
Coming soon
The Channel API will be available shortly. This page describes the interface as it will be released, so you can already plan your integration. Talk to your Customer Success Manager if you want to be among the first to use it.
# Overview
The moinAI Channel API allows you to integrate our chatbot technology into your own applications, websites, or native mobile apps. This RESTful API provides full control over conversations, enabling seamless integration with any platform.
# Getting Started
# Prerequisites
- A moinAI bot configured in your account
- A Channel API connection on that bot. moinAI sets it up for you and hands you the
botId,channelId, API key and webhook secret. - An HTTPS endpoint of your own that receives webhooks. Every bot response is delivered there, so without one you will not see what the bot says.
# Base URL
https://bot.moin.ai/api/v1/channel/{botId}/channels/{channelId}
Replace {botId} with your unique bot identifier and {channelId} with the
channelId you were given. Both are required — every request addresses one
specific Channel API connection, and your API key is checked against it.
# Authentication
All API requests must include your API key in the request header:
X-API-Key: your-api-key-here
# Example Request
curl -X POST https://bot.moin.ai/api/v1/channel/my-bot/channels/my-channel/conversations \
-H "X-API-Key: your-api-key" \
-H "Content-Type: application/json" \
-d '{"userId": "user-123"}'
# API Endpoints
# Create Conversation
Start a new conversation. You can name it yourself with conversationId — your
ticket, session or chat id, say — or leave it out and we generate one.
Request
POST /conversations
Content-Type: application/json
X-API-Key: your-api-key
Body
{
"conversationId": "chat-4711",
"userId": "your-user-123",
"context": [
{ "name": "user_email", "parameters": "user@example.com" },
{ "name": "user_name", "parameters": "John Doe" }
]
}
| Field | Type | Required | Description |
|---|---|---|---|
| conversationId | string | No | Your id for this conversation, 1–64 characters of A–Z, a–z, 0–9, _ and -. Generated (a UUID) if you leave it out |
| userId | string | No | Your reference to the person. Stored with the conversation and returned with it; it does not identify the conversation, so one user can have any number of them |
| context | array | No | Initial context variables, applied before the bot's welcome message |
Response — 201 Created
{
"v": 1,
"conversationId": "chat-4711",
"userId": "your-user-123",
"status": "active",
"createdAt": "2024-01-31T10:00:00.000Z"
}
| Field | Type | Description |
|---|---|---|
| conversationId | string | The conversation's id - the one you sent, or the one we generated. Use it for every later call; it is also the id on every webhook |
| userId | string | Your user reference, or null if you sent none |
| status | string | Conversation status (active) |
| createdAt | string | ISO 8601 timestamp of creation |
A conversation id names exactly one conversation, once. An id stays taken for as long as we keep that conversation — whether it is running, ended or expired — and starting another conversation with it is rejected rather than resuming the old one. Use an id you never reuse, such as a UUID or your own primary key:
| Code | Status | Meaning |
|---|---|---|
INVALID_CONVERSATION_ID | 400 | The id is empty, longer than 64 characters, or contains a character other than A–Z, a–z, 0–9, _ and - (a dot, for example) |
CONVERSATION_EXISTS | 409 | A conversation with this id already exists. Start the new one with a new id |
The bot's welcome message is delivered to your webhook.
# Send Message
Send a user message to the bot. The bot's response will be delivered to your webhook.
Request
POST /conversations/{conversationId}/messages
Content-Type: application/json
X-API-Key: your-api-key
Body
{
"message": {
"type": "text",
"text": "I need help with my order"
},
"context": [
{ "name": "order_id", "parameters": "ORD-12345" }
]
}
| Field | Type | Required | Description |
|---|---|---|---|
| message | object | Yes | The message object |
| message.type | string | Yes | Message type: text, intent, image, video, file |
| message.text | string | Conditional | Text content (required for type text) |
| message.intent | object | Conditional | Intent object (required for type intent) |
| message.attachment | object | Conditional | Attachment details (required for media types) |
| context | array | No | Additional context variables for this message |
| metadata.clientMessageId | string | No | Your id for this message. We use it as the message's id, so the response, the sender: "user" webhook echo and the history entry all carry it, and it comes back in metadata.clientMessageId. Generated if you leave it out |
Response (202 Accepted)
{
"v": 1,
"conversationId": "conv-abc123",
"id": "550e8400-e29b-41d4-a716-446655440000",
"type": "message",
"sender": "user",
"timestamp": "2024-01-31T10:05:00.000Z",
"components": [{ "type": "text", "value": "Hello, I have a question" }],
"status": "accepted"
}
The response is the message itself — the same id and components you will see on
the sender: "user" webhook moments later, and in the message history.
The bot's response will be delivered asynchronously to your configured webhook URL.
# Message Types
Text Message
{
"message": {
"type": "text",
"text": "Hello, I have a question"
}
}
Intent Trigger
Directly trigger a specific intent (useful for button clicks, menu selections):
{
"message": {
"type": "intent",
"intent": {
"name": "show_products",
"parameters": {
"category": "electronics"
}
}
}
}
An intent may carry text as well, and then both are kept — the intent is what the
bot plays out, the text is what your user is on record as having said:
{
"message": {
"type": "intent",
"intent": { "name": "show_products", "parameters": { "category": "electronics" } },
"text": "Show me electronics"
}
}
Which you send is your choice. An intent alone is a silent trigger, right for a
button click where the user did not say anything. An intent with text is right for a
quick reply, where they tapped something with words on it — the words belong in the
transcript, and GET /messages will have them.
Quick replies: send the intent the reply carried (it is in the quickReplies
component we sent you) along with its label as text. That is the way to do it,
since nothing has to be matched. Sending just the label as a text message is a
fallback for a client that only has the text: we match it back to the intent we
offered, which only works if the label reaches us unchanged.
Image Upload
{
"message": {
"type": "image",
"attachment": {
"url": "https://example.com/image.jpg",
"mimeType": "image/jpeg"
}
}
}
Video Upload
{
"message": {
"type": "video",
"attachment": {
"url": "https://example.com/video.mp4",
"mimeType": "video/mp4"
}
}
}
Sending a file you host yourself
{
"message": {
"type": "file",
"attachment": {
"url": "https://cdn.example.com/uploads/a1b2c3?sig=…",
"mimeType": "application/pdf",
"filename": "invoice.pdf",
"size": 48213
}
}
}
url is required — a file, image or video message without one is rejected
with 400.
The host has to be one we accept. URLs on moin.ai and its subdomains always
work — that is where a file you uploaded to us with a token lives, so passing one back
needs no setup. Any other host has to be on your channel's allowedFileHosts, which
starts empty:
| Setting | Example |
|---|---|
allowedFileHosts | ["files\\.example\\.com", ".*\\.cdn\\.example\\.com"] |
Each entry is a regular expression matched against the hostname, anchored at both
ends. Only http and https URLs are accepted. Tell us the hosts you serve files
from and we set this up for you; until then, sending a file from your own host comes
back as FILE_HOST_NOT_ALLOWED.
The reason is that we do not copy the file. We register that URL against the conversation, so the bot flow can act on the file and it appears in the conversation history — but the file itself stays on your host. Keep it reachable for as long as the conversation matters to you, and mind signed URLs that expire. It is also what your agents click on in the moinAI Hub, which is why we ask which hosts to expect rather than accepting any address.
Send mimeType, filename and size when you have them: they are what let the file
be presented properly rather than as a bare link. A missing mimeType is worked out
from the extension.
If the bot had asked for a file, storing it continues the flow just as an upload would — and the limits it asked under apply here too:
| Code | Status | Meaning |
|---|---|---|
FILE_HOST_NOT_ALLOWED | 400 | The URL's host is neither on moin.ai nor matched by your channel's allowedFileHosts, or the URL is not http/https |
FILE_TYPE_NOT_ALLOWED | 400 | Not one of the acceptedMimeTypes the bot asked for |
FILE_TOO_LARGE | 400 | Over maxFileSize. Only checked when you send size |
FILE_REQUEST_EXPIRED | 409 | Past the request's expiresAt |
If it had not asked, the file is still stored and the bot is told a file arrived unasked, which it may or may not have something to say about.
Either way the message itself behaves normally — echoed back to you, and present in
GET /messages with the file on it.
This is the alternative to the token flow under The Bot Asks for a File. Use whichever suits you: upload to us with the tokens, or host the file and send us the URL. You do not need both.
# Get Conversation
Retrieve the current state of a conversation.
Request
GET /conversations/{conversationId}
X-API-Key: your-api-key
Response
{
"v": 1,
"conversationId": "conv-abc123",
"userId": "your-user-123",
"status": "active",
"createdAt": "2024-01-31T10:00:00.000Z",
"updatedAt": "2024-01-31T10:05:00.000Z",
"messageCount": 5,
"context": [
{ "name": "user_email", "parameters": "user@example.com" }
]
}
| Field | Type | Description |
|---|---|---|
| conversationId | string | The conversation's id |
| userId | string | Your user reference, or null |
| status | string | active or ended |
| createdAt / updatedAt | string | ISO 8601 timestamps |
| endedAt | string | When it was ended. Only present once status is ended |
| messageCount | integer | Messages you have sent into the conversation |
| context | array | The conversation's context variables |
# Get Message History
Retrieve the message history for a conversation, newest first. offset=0 is the
latest page — what a client shows when it opens the conversation — and a higher
offset pages back in time. Keep paging while pagination.hasMore is true.
Request
GET /conversations/{conversationId}/messages?limit=50&offset=0
X-API-Key: your-api-key
| Parameter | Type | Default | Description |
|---|---|---|---|
| limit | integer | 50 | Maximum messages to return (max: 100) |
| offset | integer | 0 | Number of messages to skip |
Response
{
"conversationId": "conv-abc123",
"messages": [
{
"v": 1,
"id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"type": "message",
"sender": "user",
"timestamp": "2024-01-31T10:00:05.000Z",
"components": [{ "type": "text", "value": "I need help with my order" }]
},
{
"v": 1,
"id": "550e8400-e29b-41d4-a716-446655440000",
"type": "message",
"sender": "bot",
"timestamp": "2024-01-31T10:00:00.000Z",
"components": [{ "type": "text", "value": "Hello! How can I help you?" }]
}
],
"pagination": {
"total": 2,
"limit": 50,
"offset": 0,
"hasMore": false
}
}
| Field | Description |
|---|---|
pagination.total | Messages in the conversation altogether |
pagination.hasMore | Whether there are older messages beyond this page |
To show the conversation top to bottom, reverse the page you received.
# Update Context
Add or update context variables for a conversation. Setting a variable that already exists replaces its value rather than adding a second entry.
The dialog a conversation's context lives on is created when the bot handles the conversation's first message, which happens asynchronously after
POST /conversationsreturns. Calling this endpoint immediately after creating a conversation can therefore return409 CONVERSATION_NOT_READY. To set context up front, pass it toPOST /conversationsinstead — that path is applied before the bot answers, and works whether or not the user already has a dialog.
Every item must be an object with a non-empty name; anything else is rejected
with 400 INVALID_CONTEXT and the offending positions in details.invalidIndexes.
Some names are ours. The platform keeps its own state in context — which dialog this
is, what a flow is waiting for, what a provider handed back — and writing over one
does not set a variable, it tells the bot something untrue about itself. A request
naming any of them is rejected whole with 400 PROTECTED_CONTEXT, and nothing in it
is applied, so you never end up with half of your variables set:
{
"error": {
"code": "PROTECTED_CONTEXT",
"message": "These context variables are set by the platform and cannot be written over: uniqueUserId.",
"details": {
"protectedNames": ["uniqueUserId"],
"protectedIndexes": [0]
}
}
}
The same check applies to context sent with POST /conversations and alongside a
message on POST /messages.
Request
POST /conversations/{conversationId}/context
Content-Type: application/json
X-API-Key: your-api-key
Body
{
"context": [
{ "name": "user_segment", "parameters": "premium" },
{ "name": "last_purchase", "parameters": "2024-01-15" }
]
}
Response
{
"conversationId": "conv-abc123",
"status": "updated",
"context": [
{ "name": "user_email", "parameters": "user@example.com" },
{ "name": "user_segment", "parameters": "premium" },
{ "name": "last_purchase", "parameters": "2024-01-15" }
]
}
# End Conversation
End an active conversation.
Request
DELETE /conversations/{conversationId}
X-API-Key: your-api-key
Response
{
"v": 1,
"conversationId": "conv-abc123",
"status": "ended",
"endedAt": "2024-01-31T10:30:00.000Z"
}
# When a conversation ends
A conversation is in one of three states, and only you move it out of active:
| State | How it gets there | What the API answers |
|---|---|---|
active | POST /conversations | Normally |
ended | You call DELETE /conversations/{conversationId} | A new message is rejected with 409 CONVERSATION_ENDED. Reading it (GET, history) still works |
| expired | Nobody touched it for 7 days | Every call answers 404 CONVERSATION_NOT_FOUND |
- Ended is final. To carry on, start a new conversation with a new id. The old
id stays taken, so reusing it is answered with
409 CONVERSATION_EXISTS. - A live agent handover does not end the conversation. When the agent closes
the chat, the bot takes over again and the conversation stays
active. - Ended and expired mean the same thing to your client: start a new conversation.
# Conversation Language
If your bot is multilingual, it answers in the language of the person it is talking
to, and during a live agent handover the agent's messages are translated into that
language and your user's back into the agent's. Set that language with the
conversation_language context variable:
{
"context": [
{ "name": "conversation_language", "parameters": "en" }
]
}
Pass it to POST /conversations to have it in place before the bot answers, or to
POST /conversations/{conversationId}/context at any point later. It is an ordinary
context variable in every other respect — it stays set for the rest of the
conversation, though detection may still update it when your user writes in a
different language that your bot supports.
# The value
Send the lowercase two-letter code for the language — en, de, uk. That is what
we store when we work the language out ourselves, so it is what everything reading it
back expects. It is a language code, not a country code: English is en.
It also has to be a language your bot has. A multilingual bot is configured for a set of languages, and those are the ones it can be asked to answer in; a code outside that set is left alone and the bot answers in its own language. That is quiet on your side — the call that set the context still succeeds, and messages keep arriving on your webhook, just not in the language you asked for — so it is worth sending a language you know the bot has.
Two more things decide whether anything is translated at all:
- the multilingual feature has to be switched on for the bot. Without it the bot's own language is used and this variable is ignored;
- for a live agent handover, auto-translate has to be switched on as well. That is what carries the language to the agent's side, and it is passed on as you gave it — with auto-translate off, the agent writes and your user reads the same untranslated text.
# Webhooks
Every bot response is delivered to your webhook URL — there is no synchronous reply on the REST call. The same goes for the echo of your user's own messages, typing indicators and system events.
# Webhook Configuration
Two settings on your Channel API connection:
- Webhook URL: Your HTTPS endpoint
- Webhook Secret: For signature verification
They will be configurable in the moinAI Hub. Until then, moinAI sets them up for you — send us your endpoint and we hand you the secret.
# Webhook Payload
Every payload carries a v — the payload version, an integer, currently 1.
Read it first: it tells you how to interpret the rest of the object.
You opt into a version through your channel's payloadVersion setting. It is
resolved per payload rather than fixed when a conversation starts, so a change
takes effect immediately and your bot stays reachable while you migrate. One
conversation can therefore contain more than one version — a user message
rendered as v1 and the bot's reply as v2 — so your client should handle both for
as long as you are switching.
Every stored message is stamped with its version, so GET /messages returns what
was actually delivered rather than re-rendering the past at today's version. The
history envelope itself carries no v, precisely because its messages may differ.
Every non-error HTTP response from the channel carries the same v. Error
envelopes do not — { error: { code, message } } is a separate, stable contract.
Beyond that, every payload carries a type. It is a closed set of four, and it is
what you should route on first:
| type | Meaning |
|---|---|
message | Belongs in the chat history. sender says who sent it. |
indicator | Ephemeral UI feedback such as typing. Not part of the history. |
system | Conversation state changed, e.g. an agent took over. Safe to display. |
internal | 🚩For your server only. Never forward to your users. |
Everything except message also carries a kind. There is no separate event
name — type plus sender or kind is the full identity of a payload.
{
"v": 1,
"conversationId": "conv-abc123",
"id": "550e8400-e29b-41d4-a716-446655440000",
"type": "message",
"sender": "bot",
"timestamp": "2024-01-31T10:05:00.000Z",
"components": [{ "type": "text", "value": "Here's the information you requested." }],
"metadata": {
"clientMessageId": "your-message-id"
}
}
Your own user messages are echoed back with sender: "user", so your record of
the conversation is complete without merging in what you sent us.
# AI-generated messages
A message an LLM wrote carries generated: true:
{
"v": 1,
"conversationId": "conv-abc123",
"type": "message",
"sender": "bot",
"generated": true,
"components": [{ "type": "text", "value": "Our return window is 30 days." }]
}
It is only there when it applies — absent means not generated, so test for the
field being truthy rather than looking for generated: false.
It is a property of the whole message, never of a single component. An answer whose
text the model wrote but whose cards were built from a fixed template arrives as
two messages: the text with generated: true, then the cards without it. You
never have to work out which parts of one message were generated, because a message
is never partly generated.
What you do with it is up to you — a badge, a disclaimer, a different bubble style. We do not put a label in the text, so nothing has to be stripped out if you would rather not show one.
# Auto-translated messages
If your bot is multilingual, it writes in its own language and we translate before
sending. Those messages carry translation, with what the bot originally said:
{
"v": 1,
"conversationId": "conv-abc123",
"type": "message",
"sender": "bot",
"components": [{ "type": "text", "value": "Returns are possible within 30 days." }],
"translation": {
"from": "de",
"to": "en",
"original": {
"components": [{ "type": "text", "value": "Rücksendungen sind innerhalb von 30 Tagen möglich." }]
}
}
}
components is always the language your user should see. translation.original.components
is what it said before — the same structure, so whatever renders one renders the
other. You need no second code path to offer a "show original" toggle.
Quick replies are in there too, since their labels get translated as well.
| Field | Meaning |
|---|---|
from | Two-letter code of the language the bot wrote in. |
to | Two-letter code of the language it was translated into. |
original.components | The content before translation. |
The field is only present when a message was actually translated, so its presence is
the indicator — there is no translated: false to test for. from and to may
themselves be absent if we could not determine them; original is always there when
translation is.
Which language to is comes from the conversation's language — see
Conversation Language for setting it yourself.
# What the answer was based on
A message may also carry sources — the knowledge the answer drew on:
{
"v": 1,
"conversationId": "conv-abc123",
"type": "message",
"sender": "bot",
"generated": true,
"components": [{ "type": "text", "value": "Returns are within 30 days." }],
"sources": [
{ "source": "https://example.com/returns", "type": "webpage" },
{ "source": "handbook.pdf", "type": "pdf", "pages": [4, 7] }
]
}
| Field | Meaning |
|---|---|
source | What the knowledge base calls it — a URL for a web page, a document reference for a file. It identifies the source; it is not necessarily something to link to blindly. |
type | webpage, pdf, and possibly others. The set is open, so treat an unfamiliar value as "some source" rather than an error. |
pages | Which pages were used, for a source that has pages. Absent for one that does not. |
At most five per message, deduplicated — one entry per source with its pages gathered onto it, rather than the same document repeated.
Absent when there are none, so you never have to check for an empty list. And not
tied to generated: a message built from a fixed template can still tell you what
the answer behind it was based on.
# The Bot Asks for a File
When the bot's flow reaches a point that wants a file — a form step asking for a document, an action that needs a screenshot — you receive this. It is not a reply to anything you sent; it arrives when the flow gets there.
{
"v": 1,
"conversationId": "conv-abc123",
"type": "system",
"kind": "files_requested",
"timestamp": "2024-01-31T10:05:00.000Z",
"value": {
"tokens": ["tok-a", "tok-b"],
"uploadUrl": "https://api.moin.ai/files/upload/my-bot/conv-abc123",
"acceptedMimeTypes": ["image/png", "application/pdf"],
"maxFiles": 2,
"maxFileSize": 50000000,
"multiple": true,
"expiresAt": "2024-01-31T10:55:00.000Z"
}
}
| Field | Meaning |
|---|---|
tokens | One upload token per file the bot will accept. Single-use and scoped to this conversation. |
uploadUrl | Where to send the files. Given to you so you never have to hardcode one of our hostnames. |
expiresAt | When the tokens stop working — one timestamp for all of them, so you can show your user a deadline or close the picker when it passes. Typically ten minutes out, so do not sit on them. |
acceptedMimeTypes | The MIME types the bot asked for. |
maxFiles | How many files it will accept. |
maxFileSize | The largest single file, in bytes. |
multiple | Whether it expects them together rather than one at a time. |
Show a file picker and hold the conversation there — the flow is waiting for the file, and the bot will not move on until it arrives. The limits are the bot's own and are checked again on upload, so validating them in your picker is a courtesy to your user rather than the thing that enforces them.
# Uploading the files
One request per file, straight to the uploadUrl you were given. This does not go
through the channel API — no API key, no channel path:
POST {uploadUrl}
secret: tok-a
Content-Type: multipart/form-data
file=@invoice.pdf
The token goes in a secret header and the file under the field name file. That
token is the only authorization, which is why it matters that each is used once and
kept between your servers — treat it like a password with a ten-minute life.
One request per file, one token per request, and track which you have spent. Nothing tells you from outside which tokens are still good; a reused one is rejected with "Wrong token, No access". Upload as many as the request allows before moving on.
Two caveats worth coding for:
- A refused upload comes back
200with{ "status": "error", ... }in the body. Check the body, not just the status. - Uploading does not advance the conversation. The file is stored, but nothing sees it and the bot does not move — that only happens when you confirm, below. So a request for three files stays put until you say you are finished, however many you have sent.
# Telling us you are done
Then tell us — the bot is waiting and will not move on until you do.
POST /conversations/{conversationId}/files/complete
No body is needed. We read the tokens from the conversation itself, so there is nothing to pass and nothing to get wrong.
Response — 202 Accepted
{
"v": 1,
"conversationId": "conv-abc123",
"status": "accepted",
"expected": 2,
"received": 1,
"files": [
{
"name": "invoice.pdf",
"mimeType": "application/pdf",
"size": 48213,
"url": "https://api.moin.ai/files/tok-a/stored-name.pdf/invoice.pdf"
}
]
}
| Field | Meaning |
|---|---|
expected | How many files the bot issued tokens for. |
received | How many actually arrived. Fewer is fine — your user may have picked fewer. |
files | What arrived, in the order the tokens were issued. url is where you can fetch each one; it may be absent if the bot is configured not to hand out download links. |
The files also arrive on your webhook as sender: "user" messages — same ids as
these — and appear in GET /messages. So whether you build your record from the
webhook stream or from history, the files are in it rather than only the bot's reply
to them.
Every file in a conversation works this way, whoever sent it: one file component,
with sender telling you the direction, and name, mime and size on it where
we know them.
We check each token before letting the bot continue, so confirming an upload that did not happen does not advance the conversation:
| Code | Meaning |
|---|---|
409 NO_FILES_UPLOADED | None of the tokens has a file stored against it. Check the uploads succeeded, then retry. |
409 NO_FILES_REQUESTED | The bot has not asked this conversation for a file. |
409 FILES_ALREADY_CONFIRMED | Everything uploaded against this request is already confirmed. The first call went through. |
Confirm once per request. A file that is already in the conversation is not added
again, so a second call gets 409 FILES_ALREADY_CONFIRMED rather than the same files
twice — worth knowing if you retry on timeout. A file that only arrives after you
confirmed can still be confirmed by a later call, against a token you had not used
yet.
Sending the upload_success intent as a message does the same thing as this call —
same files in the history, same webhook echo, same notice to an agent — so use
whichever fits your client.
If an agent is handling the conversation, confirming is also what gives them the files. They receive a note saying how many arrived, with the files attached, and can open them from the moinAI Hub. The steps are the same as without a handover: ask for tokens, upload, confirm. Nothing reaches the agent before you confirm — an uploaded file is invisible to everyone until then.
The bot's next message arrives on your webhook as usual — the 202 only says the
confirmation was accepted.
# When the bot has not asked
A user may want to attach something the bot never requested. Ask for tokens yourself — but only while the conversation is actually allowed to send files. Two things allow it, and they are the same two the moinAI widget follows:
- The bot asked. A form step is waiting for files, under the types, size and count
it announced in
files_requested. - An agent has the conversation and livechat file upload is switched on for the bot, for as many files as that setting allows.
Outside those, the answer is 403 FILE_UPLOAD_NOT_ALLOWED. Asking repeatedly does not
create an allowance — only the bot reaching another file step, or a handover starting,
does.
POST /conversations/{conversationId}/files/tokens
{ "count": 1 }
Response — 201 Created
{
"v": 1,
"conversationId": "conv-abc123",
"tokens": ["tok-c"],
"uploadUrl": "https://api.moin.ai/files/upload/my-bot/conv-abc123",
"maxFiles": 1,
"expiresAt": "2024-01-31T10:15:00.000Z",
"requestedByBot": false
}
From there it is the same two steps: upload to uploadUrl, then
POST /files/complete.
requestedByBot is the field worth branching on. If the bot had already asked, you
get its tokens back — limits and all, with requestedByBot: true — rather than a
second set, so this is safe to call at any point and you cannot accidentally orphan a
step the bot is waiting on. When it is false nothing is waiting: the file still
appears in the conversation and on your webhook, but it will not push a flow forward.
A token is one upload slot, and an uploaded file keeps it. If the rest of your
tokens time out, calling this again replaces the unused ones only — and nothing at all
once the whole allowance has been uploaded. maxFiles says how many came back, which
may be fewer than the count you asked for, never more.
| Code | Meaning |
|---|---|
403 FILE_UPLOAD_DISABLED | File upload is switched off for this bot. |
403 FILE_UPLOAD_NOT_ALLOWED | Nothing is asking for a file right now — no file step, no agent with uploads enabled. |
409 FILE_UPLOAD_LIMIT_REACHED | Every file this conversation was allowed has been uploaded. Wait for the bot to ask again. |
400 INVALID_REQUEST | count must be a whole number from 1 to 10. |
502 FILE_TOKENS_UNAVAILABLE | Tokens could not be issued. Safe to retry. |
# Signature Verification
All webhook requests include an HMAC-SHA256 signature for verification:
X-Webhook-Signature: sha256=abc123...
X-Webhook-Timestamp: 1706695500
The signature is the HMAC-SHA256, keyed with your webhook secret, of
{X-Webhook-Timestamp}.{raw request body}
hex-encoded and prefixed with sha256=. Three rules make the check sound:
- Use the raw body, exactly as received — before any JSON parsing. Parsing and
serializing again does not reproduce our bytes in most languages: Python's
json.dumpsadds spaces after:and,and writesüasü, so a single umlaut fails the check. - Compare in constant time (
crypto.timingSafeEqual,hmac.compare_digest), never with===or==, which reveal through their timing how much of a guess was right. - Reject old timestamps, e.g. more than five minutes off your clock. The timestamp is part of what is signed, so this stops a captured webhook from being replayed later.
Verification Example (Node.js / Express)
const crypto = require('crypto');
const express = require('express');
const app = express();
const MAX_AGE_SECONDS = 300;
// Keep the raw bytes next to the parsed body - they are what is signed.
app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf; } }));
function verifyWebhookSignature(rawBody, signature, timestamp, secret) {
if (!signature || !timestamp) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > MAX_AGE_SECONDS) return false;
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post('/webhook', (req, res) => {
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
if (!verifyWebhookSignature(req.rawBody, signature, timestamp, webhookSecret)) {
return res.status(401).send('Invalid signature');
}
// Process req.body ...
res.status(200).send('OK');
});
Verification Example (Python / Flask)
import hashlib, hmac, time
from flask import Flask, request, abort
app = Flask(__name__)
MAX_AGE_SECONDS = 300
def verify_webhook_signature(raw_body: bytes, signature: str, timestamp: str, secret: str) -> bool:
if not signature or not timestamp:
return False
if abs(time.time() - int(timestamp)) > MAX_AGE_SECONDS:
return False
expected = "sha256=" + hmac.new(
secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
@app.post("/webhook")
def webhook():
if not verify_webhook_signature(
request.get_data(), # raw bytes - not request.json
request.headers.get("X-Webhook-Signature"),
request.headers.get("X-Webhook-Timestamp"),
WEBHOOK_SECRET,
):
abort(401)
payload = request.get_json()
# Process payload ...
return "OK"
# Message Components
A message's content is a list of components, not a type of its own. Text with
quick replies is two entries. Today the bot usually sends one component per
message, but treat components as a list either way.
# Text
{ "type": "text", "value": "Simple text message" }
# Image / Video
{
"type": "image",
"value": {
"src": "https://example.com/image.jpg",
"width": 800,
"height": 600,
"mime": "image/jpeg",
"alt": "Product image"
}
}
Only src is guaranteed; the rest appear when we know them. video has the same
shape.
# File
{
"type": "file",
"value": { "src": "https://example.com/terms.pdf", "mime": "application/pdf" }
}
Render as a download link. name and size may be absent.
# Quick Replies
{
"type": "quickReplies",
"value": [
{ "label": "Option A", "intent": { "name": "option_a", "parameters": {} } },
{ "label": "Option B", "intent": { "name": "option_b", "parameters": {} } }
]
}
When the user picks one, send its intent back with its label as text:
{
"message": {
"type": "intent",
"intent": { "name": "option_a", "parameters": {} },
"text": "Option A"
}
}
The intent is what the bot plays out, the label is what your user is on record as
having said. Sending only the label as a text message also works, but only as long
as it reaches us character for character — a translated, shortened or decorated
label is no longer recognised and is treated as free text.
# Buttons
{
"type": "buttons",
"value": [
{ "type": "web_url", "label": "Website", "url": "https://example.com" },
{
"type": "url_postback",
"label": "View product",
"url": "https://example.com/p1",
"intent": { "name": "pa_conversion", "parameters": { "product_id": "42" } }
}
]
}
web_url is a plain link. For url_postback, also send the supplied intent
back as a message when it is clicked — that report is what conversion tracking
is built on.
# Carousel
{
"type": "carousel",
"value": [
{
"title": "Product 1",
"description": "Description",
"banner": {
"type": "image",
"src": "https://example.com/product1.jpg",
"displayMode": "cover"
},
"buttons": [
{ "type": "web_url", "label": "View", "url": "https://example.com/p1" }
]
}
]
}
banner.displayMode is cover or contain, and worth honouring. It maps
directly onto CSS object-fit:
| Value | What to do |
|---|---|
cover | Fill the frame and crop whatever does not fit |
contain | Fit the whole image and leave space around it |
Whoever wrote the answer chose it, and they chose it because it matters for their images — a product photo cropped through the middle and a logo squashed to fill the frame are both wrong, and only the author knows which they have. It is always present on a banner, per card, so there is no default for you to guess and no precedence for you to work out. Give the banner a fixed frame (a set aspect ratio) or the two modes render identically and the setting looks broken.
# Rate Limits
| Limit | Default |
|---|---|
| Requests per minute | 600 |
| Requests per hour | 35,000 |
When rate limited, you'll receive a 429 Too Many Requests response:
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Too many requests",
"retryAfter": 30
}
}
The Retry-After header indicates seconds to wait.
# Error Handling
# Error Response Format
{
"error": {
"code": "ERROR_CODE",
"message": "Human-readable message",
"details": {}
}
}
# Error Codes
| Code | HTTP Status | Description |
|---|---|---|
INVALID_API_KEY | 401 | API key is missing, or does not match this channel |
BOT_NOT_FOUND | 404 | Bot ID does not exist |
CHANNEL_NOT_FOUND | 404 | No Channel API connection with that channelId on this bot |
INVALID_CONTEXT | 400 | A context item has no usable name |
PROTECTED_CONTEXT | 400 | A context item names a variable the platform owns — see Update Context |
CONVERSATION_NOT_READY | 409 | The conversation has no dialog yet — see Update Context |
LIVECHAT_NOT_CONFIGURED | 400 | This bot has no moinAI Livechat configuration |
HANDOVER_ALREADY_ACTIVE | 400 | A handover is already running for this conversation |
HANDOVER_NOT_ACTIVE | 400 | No handover is running yet, or it has already ended |
HANDOVER_CLAIM_FAILED | 409 | Someone claimed it first, or the agent is at maxOpenChats |
HANDOVER_NOT_CLAIMED | 409 | Nobody has claimed this conversation — claim it first |
AGENT_NOT_CLAIMANT | 403 | Another agent holds this conversation |
HANDOVER_END_FAILED | 502 | The handover could not be ended — safe to retry |
AGENT_PRESENCE_FAILED | 502 | Presence could not be updated — safe to retry |
AGENT_MESSAGE_FAILED | 502 | The agent message was not accepted — safe to retry |
AGENT_EVENT_FAILED | 502 | The event was not delivered — do not retry, it has passed |
NO_FILES_REQUESTED | 409 | The bot has not asked this conversation for a file |
FILES_ALREADY_CONFIRMED | 409 | Everything uploaded against this file request has already been confirmed |
NO_FILES_UPLOADED | 409 | None of the upload tokens has a file stored against it |
FILE_REQUEST_EXPIRED | 409 | Past the file request's expiresAt — its tokens no longer work |
FILE_TYPE_NOT_ALLOWED | 400 | Not one of the acceptedMimeTypes the bot asked for |
FILE_TOO_LARGE | 400 | Over the request's maxFileSize |
FILE_HOST_NOT_ALLOWED | 400 | The file URL's host is neither on moin.ai nor matched by your channel's allowedFileHosts |
FILE_UPLOAD_DISABLED | 403 | File upload is switched off for this bot, so no tokens can be issued |
FILE_TOKENS_UNAVAILABLE | 502 | Upload tokens could not be issued — safe to retry |
API_CHANNEL_DISABLED | 403 | The channel exists but is switched off |
CONVERSATION_NOT_FOUND | 404 | Conversation does not exist, or expired after 7 days without activity |
INVALID_CONVERSATION_ID | 400 | A conversation id is empty, over 64 characters, or uses a character other than A–Z, a–z, 0–9, _, - |
CONVERSATION_EXISTS | 409 | POST /conversations with an id that is already taken — start the new conversation with a new id |
CONVERSATION_ENDED | 409 | The conversation was ended with DELETE and takes no more messages — start a new one |
INVALID_MESSAGE_TYPE | 400 | Unsupported message type |
INVALID_REQUEST | 400 | Request body validation failed, or a path segment is missing |
RATE_LIMIT_EXCEEDED | 429 | Too many requests |
INTERNAL_ERROR | 500 | Server error |
# Best Practices
# 1. Understand conversationId vs userId
| Field | Source | Purpose |
|---|---|---|
conversationId | Chosen by you, or generated by moinAI | Identifies one conversation - used in every API call and on every webhook |
userId | Provided by you (optional) | Your reference to the person, returned with the conversation |
- Pass your own
conversationIdif you already have an id for the chat — then you need no mapping table between your ids and ours. It must be unique: an id names one conversation, once. userIddoes not identify a conversation. The same user can have any number of conversations, each with its ownconversationId.
# 2. Implement Robust Webhook Handling
Your webhook endpoint should:
- Respond quickly (within 5 seconds) with HTTP 200
- Process messages asynchronously if needed
- Handle duplicate deliveries (use message IDs for idempotency)
- Return appropriate error codes for moinAI retry logic
# 3. Verify Webhook Signatures
Always verify webhook signatures to ensure requests originate from moinAI — over the raw request body, in constant time, with a timestamp check. See Signature Verification:
if (!verifyWebhookSignature(req.rawBody, signature, timestamp, secret)) {
return res.status(401).send('Unauthorized');
}
# 4. Handle Quick Replies
When the user clicks a quick reply, send its intent with its label as text:
{
"message": {
"type": "intent",
"intent": { "name": "option_a", "parameters": {} },
"text": "Option A"
}
}
# 5. Handle Connection Issues
Implement proper error handling for API calls:
- Retry on 5xx errors with exponential backoff
- Don't retry on 4xx errors (fix the request instead)
- Set appropriate timeouts (recommended: 10 seconds)
# 6. Use Context Effectively
Pass relevant context with messages to improve bot responses:
{
"context": [
{ "name": "page", "parameters": "checkout" },
{ "name": "cart_value", "parameters": "149.99" }
]
}
# SDK Examples
# Node.js
const axios = require('axios');
class MoinAIClient {
constructor(botId, channelId, apiKey) {
this.client = axios.create({
baseURL: `https://bot.moin.ai/api/v1/channel/${botId}/channels/${channelId}`,
headers: {
'X-API-Key': apiKey,
'Content-Type': 'application/json'
},
timeout: 10000
});
}
async createConversation(options = {}) {
const { conversationId, userId, context = [] } = options;
const { data } = await this.client.post('/conversations', {
conversationId, // Optional: your id for the conversation (generated if omitted)
userId, // Optional: your reference to the person
context
});
// Returns: { v, conversationId, userId, status, createdAt }
// conversationId = the id to use for all later calls
return data;
}
async sendMessage(conversationId, text, context = []) {
const { data } = await this.client.post(
`/conversations/${conversationId}/messages`,
{
message: { type: 'text', text },
context
}
);
// Returns the accepted message: { conversationId, id, type, sender, timestamp, components, status }
// Bot response will be delivered via webhook
return data;
}
// For a quick reply, pass its label as `text` so the history shows what was picked.
async sendIntent(conversationId, intentName, parameters = {}, text) {
const { data } = await this.client.post(
`/conversations/${conversationId}/messages`,
{
message: {
type: 'intent',
intent: { name: intentName, parameters },
...(text ? { text } : {})
}
}
);
return data;
}
async getConversation(conversationId) {
const { data } = await this.client.get(
`/conversations/${conversationId}`
);
return data;
}
async getHistory(conversationId, limit = 50) {
const { data } = await this.client.get(
`/conversations/${conversationId}/messages`,
{ params: { limit } }
);
return data;
}
async updateContext(conversationId, context) {
const { data } = await this.client.post(
`/conversations/${conversationId}/context`,
{ context }
);
return data;
}
async endConversation(conversationId) {
const { data } = await this.client.delete(
`/conversations/${conversationId}`
);
return data;
}
}
// Usage
const client = new MoinAIClient('my-bot-id', 'my-channel-id', 'my-api-key');
// Start a conversation under your own chat id
const conversation = await client.createConversation({
conversationId: 'chat-4711', // Optional: generated if omitted
userId: 'your-user-123', // Optional: your reference to the person
context: [{ name: 'user_email', parameters: 'user@example.com' }]
});
// Use conversationId for all API calls - it is also on every webhook
console.log('Conversation ID:', conversation.conversationId);
// Send message using conversationId - response comes via webhook
await client.sendMessage(conversation.conversationId, 'Hello!');
// Handle a quick reply click: its intent, with its label as text
await client.sendIntent(conversation.conversationId, 'track_order', {}, 'Track my order');
# Python
import requests
class MoinAIClient:
def __init__(self, bot_id, channel_id, api_key):
self.base_url = f"https://bot.moin.ai/api/v1/channel/{bot_id}/channels/{channel_id}"
self.headers = {
"X-API-Key": api_key,
"Content-Type": "application/json"
}
self.timeout = 10
def create_conversation(self, conversation_id=None, user_id=None, context=None):
body = {"userId": user_id, "context": context or []}
if conversation_id:
body["conversationId"] = conversation_id # Optional: generated if omitted
response = requests.post(
f"{self.base_url}/conversations",
headers=self.headers,
json=body,
timeout=self.timeout
)
response.raise_for_status()
# Returns: { v, conversationId, userId, status, createdAt }
# conversationId = the id to use for all later calls
return response.json()
def send_message(self, conversation_id, text, context=None):
response = requests.post(
f"{self.base_url}/conversations/{conversation_id}/messages",
headers=self.headers,
json={
"message": {"type": "text", "text": text},
"context": context or []
},
timeout=self.timeout
)
response.raise_for_status()
# Bot response will be delivered via webhook
return response.json()
# For a quick reply, pass its label as text so the history shows what was picked.
def send_intent(self, conversation_id, intent_name, parameters=None, text=None):
message = {
"type": "intent",
"intent": {"name": intent_name, "parameters": parameters or {}}
}
if text:
message["text"] = text
response = requests.post(
f"{self.base_url}/conversations/{conversation_id}/messages",
headers=self.headers,
json={"message": message},
timeout=self.timeout
)
response.raise_for_status()
return response.json()
def get_history(self, conversation_id, limit=50):
response = requests.get(
f"{self.base_url}/conversations/{conversation_id}/messages",
headers=self.headers,
params={"limit": limit},
timeout=self.timeout
)
response.raise_for_status()
return response.json()
def end_conversation(self, conversation_id):
response = requests.delete(
f"{self.base_url}/conversations/{conversation_id}",
headers=self.headers,
timeout=self.timeout
)
response.raise_for_status()
return response.json()
# Usage
client = MoinAIClient("my-bot-id", "my-channel-id", "my-api-key")
# Start a conversation under your own chat id
conversation = client.create_conversation(
conversation_id="chat-4711", # Optional: generated if omitted
user_id="your-user-123", # Optional: your reference to the person
context=[{"name": "user_email", "parameters": "user@example.com"}]
)
# Use conversationId for all API calls - it is also on every webhook
print(f"Conversation ID: {conversation['conversationId']}")
# Send message using conversationId - response comes via webhook
client.send_message(conversation["conversationId"], "Hello!")
# Handle a quick reply click: its intent, with its label as text
client.send_intent(conversation["conversationId"], "track_order", text="Track my order")
# Live Agent Handover
The Channel API supports seamless handover to human agents: the moinAI bot handles routine inquiries, and complex ones reach a person.
Handovers run through moinAI Livechat. Your agents can work in the moinAI hub dashboard, in a client of your own, or both - it is the same handover either way, and the conversation, its history and its archive are the same records.
# Working in your own agent client
If your agents work in the hub dashboard, you can skip this section: agent
messages and agent details arrive on your webhook without you doing anything, and
POST /handover is the only endpoint below you need.
If your agents work in a client of your own, you send their side over REST. Three things the hub's live connection would have handled are then yours to do:
- Say when an agent is available.
PUT /agents/presenceis what puts an agent online. Until an agent is online,GET /agents/availabilityreportsunavailableand a bot that checks before offering a handover will not offer one. Say so again when they step away or finish their shift - nothing times out on its own. - Use a stable
agent.id, and claim before you speak. It is how we recognise the same agent across calls, so a new id each time creates a new agent each time. Every message and event carries it, and is only accepted from the agent who claimed that conversation — the same rule the hub enforces by only letting an agent open a chat assigned to them. There is no anonymous or system agent to send as. - Translate your own messages. The hub translates an agent's outgoing message into the visitor's language; text you send over REST is delivered exactly as you wrote it.
# Overview
Live Agent Handover provides:
- Seamless Escalation: Automatically or manually transfer conversations from bot to human agents
- Agent Availability: Real-time checking of agent availability before handover
- Queue Management: Handle situations when agents are busy or offline
- Bidirectional Messaging: Full message exchange between agents and users
- State Tracking: Monitor handover status throughout the lifecycle
- Typing Indicators: Real-time typing status for enhanced user experience
- Agent Information: Display agent details (name, avatar, title) to users
# Handover States
A conversation can be in one of the following handover states:
| State | Description |
|---|---|
none | No handover active, bot is handling the conversation |
requested | Handover has been requested, waiting for agent assignment |
queued | User is in queue, waiting for an available agent |
active | Agent is actively handling the conversation |
ended | Handover has ended, bot may resume control |
# Handover Flow
┌─────────────────────────────────────────────────────────────────┐
│ HANDOVER LIFECYCLE │
└─────────────────────────────────────────────────────────────────┘
User chatting with Bot
│
▼
┌─────────────────┐
│ Bot detects │ Triggers: user request, sentiment,
│ escalation need │ specific intent, or your API call
└────────┬────────┘
│
▼
┌─────────────────┐ Webhook: handover_requested
│ Status: │────────────────────────────────────► Your System
│ REQUESTED │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Check Agent │◄──── GET /agents/availability
│ Availability │ (hub agents who are logged in, plus your own
└────────┬────────┘ agents you reported as online)
│
┌─────┴─────┐
│ │
▼ ▼
Available Unavailable
│ │
│ ▼
│ ┌─────────────────┐
│ │ Status: QUEUED │ User sees queue message
│ │ or CANCEL │ or handover is cancelled
│ └────────┬────────┘
│ │
│ (Agent becomes available)
│ │
▼ ▼
┌─────────────────┐ POST /handover/agent
│ Assign Agent │◄──────────────────────────────────── Your System
└────────┬────────┘
│
▼
┌─────────────────┐ Webhook: agent_assigned
│ Status: ACTIVE │────────────────────────────────────► Your System
└────────┬────────┘
│
▼
┌─────────────────┐
│ Agent & User │◄───► Messages flow through API
│ Conversation │
└────────┬────────┘
│
▼
┌─────────────────┐ POST /handover/end
│ End Handover │◄──────────────────────────────────── Your System
└────────┬────────┘
│
▼
┌─────────────────┐ Webhook: handover_ended
│ Status: ENDED │────────────────────────────────────► Your System
│ Bot resumes │
└─────────────────┘
# Handover Endpoints
# Initiate Handover
Ask the bot to hand this conversation to a live agent. This raises the same request
the bot raises when a user asks for a human itself, so the bot's own live chat
configuration — agent groups, language, and the context the agent is shown — applies
exactly as it otherwise would. group, when given, is used as the preferred group.
POST /conversations/{conversationId}/handover
{
"group": "support-team",
"reason": "customer_request"
}
Response
{
"v": 1,
"conversationId": "conv-abc123",
"status": "requested",
"group": "support-team",
"reason": "customer_request",
"availability": "waiting",
"agent": null
}
availability tells you what the request actually bought:
| Value | What it means |
|---|---|
available | An agent is free and should pick it up shortly |
waiting | Agents exist but are all busy — the conversation is queued |
unavailable | Nobody can take it. It is still queued, but do not promise your user a reply |
Note that waiting and unavailable are not failures. The handover is raised
either way and the conversation joins the unclaimed queue; the value is there so you
can say something honest to your user instead of leaving them waiting on a silent
screen.
The same three arrive on your webhook as agent_available / agent_waiting /
agent_unavailable, followed by handover_requested. Then agent_assigned once
someone picks it up, which is where you get the agent's name and profile.
A bot with no live chat configuration is rejected with
400 LIVECHAT_NOT_CONFIGURED, and asking twice while one is running with
400 HANDOVER_ALREADY_ACTIVE.
# Get Handover Status
GET /conversations/{conversationId}/handover
Response
{
"conversationId": "conv-abc123",
"status": "active",
"requestedAt": "2024-01-31T10:00:00.000Z",
"assignedAt": "2024-01-31T10:01:00.000Z",
"agent": {
"id": "agent-001",
"name": "John Smith",
"avatarUrl": "https://example.com/avatar.jpg",
"title": "Support Agent"
}
}
# Set Agent Presence
Put one of your own agents online, or take them off. Only needed if your agents work outside the hub dashboard.
An agent counts towards GET /agents/availability only while they are online.
Nothing infers that for you, and nothing expires: an agent stays online until you
say otherwise, so send away or offline when they step away or their shift
ends.
This is the one handover endpoint that is not about a single conversation.
PUT /agents/presence
{
"status": "online",
"agent": {
"id": "agent-001",
"name": "John Smith",
"avatarUrl": "https://example.com/avatar.jpg",
"title": "Support Agent",
"groups": ["support-team"],
"maxOpenChats": 20
}
}
status is online, away or offline. groups defaults to every group your
bot has. maxOpenChats is how many conversations this agent can hold at once -
availability reports waiting rather than available once your agents are full.
Response
{
"v": 1,
"agent": {
"id": "agent-001",
"name": "John Smith",
"avatarUrl": "https://example.com/avatar.jpg",
"title": "Support Agent",
"groups": ["support-team"],
"maxOpenChats": 20,
"status": "online"
}
}
# Claim Conversation
Take the conversation for one of your own agents. Only needed if your agents work outside the hub dashboard - a hub agent claims it in the dashboard instead.
Claiming is what makes the conversation theirs: it leaves the hub's queue, so a hub agent cannot pick up a conversation your agent is already handling, and the user is told who they are talking to.
POST /conversations/{conversationId}/handover/agent
{
"agent": {
"id": "agent-001",
"name": "John Smith",
"avatarUrl": "https://example.com/avatar.jpg",
"title": "Support Agent"
}
}
Both id and name are required.
Claim once the handover has actually started, not the moment you asked for it.
handover_requested on your webhook tells you it was asked for; GET /handover
reporting requested tells you it is ready. Claiming earlier answers
400 HANDOVER_NOT_ACTIVE — retry.
409 means someone else claimed it first, or the agent is already at
maxOpenChats.
# Send Agent Message
Send a message from the agent to the user. text, an attachment with a url,
or both.
To send a file, upload it to us first — POST /files/message, multipart, field
file — and pass the location you get back as attachment.url, along with
name, mimeType, extension and size. This is exactly what the hub dashboard
does when an agent attaches something, so the file is in our storage before the
message exists. An attachment without a url is rejected with 400.
agent.id is required, and must be the agent who claimed this conversation — a
message always belongs to the agent handling it. You get 409 if nobody has
claimed it yet and 403 if another agent has, including one working in the hub
dashboard.
POST /conversations/{conversationId}/handover/message
{
"message": {
"text": "Hello! I'm John and I'll be helping you today."
},
"agent": {
"id": "agent-001"
},
"requestId": "your-own-id"
}
Response — 202 Accepted
{
"v": 1,
"conversationId": "conv-abc123",
"requestId": "your-own-id",
"status": "accepted",
"timestamp": "2024-01-31T10:02:00.000Z"
}
The message itself arrives on your webhook as a sender: "agent" message, which
is also the copy GET /messages will report. requestId is echoed back so you
can match this acknowledgement to what you sent; it is not the message's id.
Text is sent exactly as you wrote it — nothing is translated on your behalf.
# Send Agent Event
Send something alongside the conversation rather than in it: a typing indicator, a progress update, or anything your agent client and your own client understand between them.
Same rule as a message: agent.id is required and must be the agent who claimed
the conversation.
POST /conversations/{conversationId}/handover/event
{
"kind": "typing",
"value": { "typing": true },
"agent": { "id": "agent-001" }
}
typing reaches you as an indicator payload, the same one the bot sends. Any
other kind reaches you as a system payload with kind: "agent_event",
carrying your own kind and value untouched — so you can send whatever your two
clients agree on without us needing to know about it.
Events are never stored and never appear in the conversation history. They
describe a moment, so delivery is attempted once and you get 202 either way.
# End Handover
End the handover and return control to the bot. This also closes the conversation on the agent's side, so it does not stay open once they are done with it.
POST /conversations/{conversationId}/handover/end
{
"reason": "resolved_by_agent"
}
# Cancel Handover
Cancel a pending handover request.
POST /conversations/{conversationId}/handover/cancel
{
"reason": "user_cancelled"
}
Possible Cancel Reasons
| Reason | Description |
|---|---|
user_cancelled | User decided they don't need agent help |
no_agents_available | No agents are available to handle the request |
timeout | Handover request timed out |
bot_resolved | Bot was able to resolve the issue |
# Check Agent Availability
Asks moinAI Livechat whether an agent can take this conversation.
GET /conversations/{conversationId}/agents/availability?group={group}
| Parameter | Type | Required | Description |
|---|---|---|---|
| group | string | No | The agent group to ask about. Omit to ask about every group the bot has. |
Response
{
"v": 1,
"conversationId": "conv-abc123",
"group": "support-team",
"checkedAt": "2024-01-31T10:00:00.000Z",
"availability": "available"
}
| Field | Type | Description |
|---|---|---|
| conversationId | string | The conversation you asked about |
| group | string | null | The group you named, or null if you named none |
| checkedAt | string | ISO 8601 timestamp of the check |
| availability | string | available, waiting or unavailable |
| reason | string | Only when the bot has no live chat set up: no_livechat_configured |
| availability | Meaning |
|---|---|
available | An agent can take the conversation now. |
waiting | Agents exist but are busy — the user would wait in a queue. |
unavailable | Nobody can take it, or moinAI Livechat could not be reached. |
A failure to reach moinAI Livechat reports
unavailablerather than an error, so a hiccup never reads as "an agent is standing by".
Use Case: Check Before Handover
const { availability } = await moinAI.checkAgentAvailability(conversationId, 'support');
if (availability === 'available') {
await moinAI.initiateHandover(conversationId, { group: 'support' });
} else if (availability === 'waiting') {
// Agents are busy - warn about the wait, then hand over anyway if the user agrees.
await askUserToWaitOrLeaveMessage(conversationId);
} else {
await offerOfflineOptions(conversationId);
}
# User Messages During Handover
When a conversation is in handover state (requested, queued, or active), user messages are handled differently:
- During
requestedorqueued: User messages are stored and will be delivered to the agent when assigned - During
active: User messages are forwarded directly to the assigned agent via webhook
Webhook for User Messages During Handover
{
"v": 1,
"conversationId": "conv-abc123",
"id": "550e8400-e29b-41d4-a716-446655440000",
"type": "message",
"sender": "user",
"timestamp": "2024-01-31T10:05:00.000Z",
"components": [{ "type": "text", "value": "I'm still waiting for help with my order" }]
}
# Typing Indicators
The API supports typing indicators to provide real-time feedback during conversations. This is especially useful during handover when users are waiting for agent responses.
# Send Typing Indicator (Agent)
Indicate that one of your own agents is typing. This is an agent event, so the agent must have claimed the conversation first.
POST /conversations/{conversationId}/handover/event
{
"kind": "typing",
"value": { "typing": true },
"agent": { "id": "agent-001" }
}
To stop the typing indicator:
{
"kind": "typing",
"value": { "typing": false },
"agent": { "id": "agent-001" }
}
# Receive Typing Indicator
When the bot — or an agent working in the hub dashboard — is composing a reply, you'll receive a webhook event:
{
"v": 1,
"conversationId": "conv-abc123",
"type": "indicator",
"kind": "typing",
"timestamp": "2024-01-31T10:05:00.000Z",
"value": true
}
value is true when composing starts and false when it stops. Typing events
need no setting — they are sent whenever the bot's flow has work to do before it
answers (looking something up, running an action) and while a hub agent types.
A quick, fixed answer can arrive without one, so never wait for a typing event.
While an AI answer is being worked out, you may also receive thinking — the
step the AI agent is on, with a short title you can show instead of a plain
typing indicator:
{
"v": 1,
"conversationId": "conv-abc123",
"type": "indicator",
"kind": "thinking",
"timestamp": "2024-01-31T10:05:00.000Z",
"value": { "step": "search", "title": "Searching the knowledge base" }
}
Like every indicator, both are ephemeral: not part of the history, and safe to
ignore if your client shows no such feedback. Treat an unfamiliar kind the same
way.
# Queue Management
When agents are busy, you can implement queue management to handle waiting users.
# Update Queue Position
Keep users informed about their queue position.
POST /conversations/{conversationId}/handover
{
"status": "queued",
"queuePosition": 3,
"estimatedWaitTime": 180
}
This will trigger a message to the user informing them of their queue position.
# Queue Best Practices
- Provide Regular Updates: Send queue position updates every 30-60 seconds
- Set Expectations: Show estimated wait time when available
- Offer Alternatives: Allow users to leave a message or callback request
- Handle Abandonment: If user leaves, properly end the queued handover
Example: Queue Position Update Flow
async function updateQueuePosition(conversationId) {
const queueInfo = await getQueueInfo(conversationId);
if (queueInfo.position > 0) {
// Still in queue - update position
await moinAI.updateHandoverStatus(conversationId, {
status: 'queued',
queuePosition: queueInfo.position,
estimatedWaitTime: queueInfo.estimatedWait
});
// Schedule next update
setTimeout(() => updateQueuePosition(conversationId), 30000);
} else {
// Ready to assign agent
const agent = await getNextAvailableAgent();
await moinAI.assignAgent(conversationId, agent);
}
}
# Handover Webhook Payloads
Handover payloads are all type: "system" and differ by kind:
| kind | value | Description |
|---|---|---|
agent_available | { group? } | An agent can take over |
agent_waiting | { group? } | Agents are busy — the conversation is queued |
agent_unavailable | { reason, group? } | No agent can take over |
handover_requested | { reason, group?, availability } | The handover was raised |
agent_assigned | The agent object | An agent picked up the conversation |
handover_ended | { reason, endedBy } | Control returns to the bot |
agent_event | { kind, value } | An agent sent something alongside the conversation |
The availability three arrive whenever a handover is attempted — from your
POST /handover, and also when the bot escalates on its own, which you did not ask
for and should still be told about. handover_requested follows your own call only.
agent_assigned comes later and independently — you cannot know when, because it
depends on a human. It arrives whether the agent claimed from the moinAI Hub or
through their own client against POST /handover/agent.
Messages from the agent and from the user during handover are not system payloads —
they are ordinary messages with type: "message" and sender: "agent" or
"user". Typing is type: "indicator", kind: "typing".
An agent_event you do not recognise is safe to ignore. Its kind and value
are whatever the sending agent client chose; we pass them through without
interpreting them, which is also how they can carry something specific to your
own integration.
handover_requested
{
"v": 1,
"conversationId": "conv-abc123",
"type": "system",
"kind": "handover_requested",
"timestamp": "2024-01-31T10:00:00.000Z",
"value": {
"reason": "customer_request",
"group": "support-team",
"availability": "waiting"
}
}
reason is whatever you sent on the request, or requested_via_api if you sent
none. availability repeats what the 201 told you, so a webhook-only consumer sees
it too.
agent_unavailable
{
"v": 1,
"conversationId": "conv-abc123",
"type": "system",
"kind": "agent_unavailable",
"timestamp": "2024-01-31T10:00:05.000Z",
"value": {
"reason": "no_agents_available",
"group": "support-team"
}
}
agent_available has the same shape without reason — the kind already says
which of the two it is.
agent_assigned
{
"v": 1,
"conversationId": "conv-abc123",
"type": "system",
"kind": "agent_assigned",
"timestamp": "2024-01-31T10:01:00.000Z",
"value": {
"id": "8f2c1b04-5a6d-4e91-9c3a-77b1e0d4f882",
"name": "John Smith",
"avatarUrl": "https://example.com/avatar.jpg",
"title": "Support Agent",
"email": "john.smith@example.com",
"nick": "John Smith"
}
}
This is where the agent's details come from — there is no endpoint to ask for them,
because until a human claims the conversation there is nobody to ask about. Render
name, title and avatarUrl above the chat; correlate on id, which is the
agent's identifier in our system and the only field that is stable. Any field may be
null if the agent has not filled it in.
Message from the agent
{
"v": 1,
"conversationId": "conv-abc123",
"id": "550e8400-e29b-41d4-a716-446655440000",
"type": "message",
"sender": "agent",
"timestamp": "2024-01-31T10:02:00.000Z",
"components": [{ "type": "text", "value": "Hello! I'll be helping you today." }],
"agent": { "id": "agent-001", "name": "John Smith" }
}
handover_ended
{
"v": 1,
"conversationId": "conv-abc123",
"type": "system",
"kind": "handover_ended",
"timestamp": "2024-01-31T10:30:00.000Z",
"value": {
"reason": "resolved_by_agent",
"endedBy": "agent"
}
}
| End Reason | Description |
|---|---|
resolved_by_agent | Agent marked the issue as resolved |
finished | Bot ended the handover |
cancelled_by_api | Handover was cancelled via the API |
# Comprehensive Handover Integration Example
Here's a complete example showing how to integrate the moinAI Channel API with your live chat system:
const express = require('express');
const crypto = require('crypto');
const axios = require('axios');
const app = express();
// Keep the raw bytes next to the parsed body - the signature is over them.
app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf; } }));
// Configuration
const MOINAI_API_KEY = process.env.MOINAI_API_KEY;
const MOINAI_BOT_ID = process.env.MOINAI_BOT_ID;
const MOINAI_CHANNEL_ID = process.env.MOINAI_CHANNEL_ID;
const WEBHOOK_SECRET = process.env.MOINAI_WEBHOOK_SECRET;
// moinAI API Client
const moinAI = axios.create({
baseURL: `https://bot.moin.ai/api/v1/channel/${MOINAI_BOT_ID}/channels/${MOINAI_CHANNEL_ID}`,
headers: {
'X-API-Key': MOINAI_API_KEY,
'Content-Type': 'application/json'
}
});
// Verify webhook signature - see "Signature Verification"
function verifySignature(rawBody, signature, timestamp) {
if (!signature || !timestamp) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = Buffer.from('sha256=' + crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(`${timestamp}.`)
.update(rawBody)
.digest('hex'));
const received = Buffer.from(signature);
return expected.length === received.length && crypto.timingSafeEqual(expected, received);
}
// Main webhook handler
app.post('/moinai/webhook', async (req, res) => {
// Verify signature
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
if (!verifySignature(req.rawBody, signature, timestamp)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const { type, sender, kind, conversationId, value } = req.body;
try {
// Route on `type` first, then narrow on `sender` for messages and `kind`
// for everything else.
switch (type) {
case 'message':
if (sender === 'user') {
await forwardMessageToAgent(conversationId, req.body);
}
break;
case 'indicator':
if (kind === 'typing') {
await notifyAgentOfUserTyping(conversationId, value);
}
break;
case 'system':
if (kind === 'handover_requested') {
await handleHandoverRequest(conversationId, value);
} else if (kind === 'handover_ended') {
await cleanupHandover(conversationId, value);
}
break;
case 'internal':
// Never forward these to a user - they may carry sensitive data.
await handleInternalAlert(conversationId, kind, value);
break;
}
res.status(200).json({ success: true });
} catch (error) {
console.error('Webhook error:', error);
res.status(500).json({ error: 'Internal error' });
}
});
// ═══════════════════════════════════════════════════════════════════
// HANDOVER REQUEST HANDLING
// ═══════════════════════════════════════════════════════════════════
async function handleHandoverRequest(conversationId, data) {
console.log(`Handover requested for ${conversationId}`, data);
// Step 1: Check agent availability
const availability = await checkAgentAvailability(data.group);
if (!availability.available) {
// No agents available - offer options
if (availability.reason === 'outside_business_hours') {
// Outside business hours - cancel and let bot handle
await moinAI.post(`/conversations/${conversationId}/handover/cancel`, {
reason: 'outside_business_hours'
});
return;
}
// All agents busy - queue the user
await queueUser(conversationId, data);
return;
}
// Step 2: Find and assign available agent
const agent = await findBestAgent(data.group, data.context);
await moinAI.post(`/conversations/${conversationId}/handover/agent`, {
agent: {
id: agent.id,
name: agent.name,
avatarUrl: agent.avatarUrl,
title: agent.title,
email: agent.email
}
});
// Step 3: Notify agent in your system
await notifyAgent(agent.id, {
conversationId,
context: data.context,
userHistory: await getConversationHistory(conversationId)
});
}
async function queueUser(conversationId, data) {
const queuePosition = await addToQueue(conversationId, data.group);
// Update moinAI with queue status
await moinAI.post(`/conversations/${conversationId}/handover`, {
status: 'queued',
queuePosition,
estimatedWaitTime: queuePosition * 120 // 2 min per position
});
// Start monitoring queue
monitorQueue(conversationId);
}
function monitorQueue(conversationId) {
const checkInterval = setInterval(async () => {
const queueInfo = await getQueuePosition(conversationId);
if (!queueInfo) {
// No longer in queue
clearInterval(checkInterval);
return;
}
if (queueInfo.position === 0) {
// User is next - assign agent
clearInterval(checkInterval);
const agent = await getNextAvailableAgent(queueInfo.group);
if (agent) {
await moinAI.post(`/conversations/${conversationId}/handover/agent`, {
agent
});
}
} else {
// Update queue position
await moinAI.post(`/conversations/${conversationId}/handover`, {
status: 'queued',
queuePosition: queueInfo.position,
estimatedWaitTime: queueInfo.position * 120
});
}
}, 30000); // Check every 30 seconds
}
// ═══════════════════════════════════════════════════════════════════
// AGENT MESSAGE HANDLING
// ═══════════════════════════════════════════════════════════════════
// Call this from your agent interface when agent sends a message.
// The agent must already have claimed this conversation - every call below is
// refused otherwise, and the id has to be the one they claimed with.
async function sendAgentMessage(conversationId, text, agent) {
// Show the agent as typing while they compose. Events are fire-and-forget,
// so a failed one is not worth acting on.
await moinAI.post(`/conversations/${conversationId}/handover/event`, {
kind: 'typing',
value: { typing: true },
agent: { id: agent.id }
});
// Small delay for natural feel
await new Promise(resolve => setTimeout(resolve, 500));
await moinAI.post(`/conversations/${conversationId}/handover/event`, {
kind: 'typing',
value: { typing: false },
agent: { id: agent.id }
});
// Translate here if your agents and visitors do not share a language - we send
// the text exactly as given.
await moinAI.post(`/conversations/${conversationId}/handover/message`, {
message: { text },
agent: { id: agent.id }
});
// The message itself comes back on your webhook as sender: "agent". This call
// only answers 202 - it does not return the stored message.
}
// Call this when agent ends the conversation
async function endAgentConversation(conversationId, reason = 'resolved_by_agent') {
await moinAI.post(`/conversations/${conversationId}/handover/end`, {
reason
});
}
// ═══════════════════════════════════════════════════════════════════
// HELPER FUNCTIONS (implement based on your system)
// ═══════════════════════════════════════════════════════════════════
async function checkAgentAvailability(group) {
// Check your system for available agents
// Return: { available: boolean, reason?: string }
}
async function findBestAgent(group, context) {
// Find the best agent based on skills, load, etc.
// Return: { id, name, avatarUrl, title, email }
}
async function forwardMessageToAgent(conversationId, messages, agent) {
// Forward user message to agent in your system
}
async function notifyAgentOfUserTyping(conversationId, isTyping) {
// Update typing indicator in agent interface
}
async function cleanupHandover(conversationId, data) {
// Clean up resources when handover ends
console.log(`Handover ended: ${data.reason} by ${data.endedBy}`);
}
async function getConversationHistory(conversationId) {
const response = await moinAI.get(`/conversations/${conversationId}/messages`);
return response.data.messages;
}
// Start server
app.listen(3000, () => console.log('Webhook server running on port 3000'));
# Handover Integration Example (Simple)
For simpler integrations, here's a minimal example:
// Handling handover in your webhook
app.post('/webhook', async (req, res) => {
const { type, sender, kind, conversationId, value } = req.body;
if (type === 'system' && kind === 'handover_requested') {
// Check agent availability in your system
const agentAvailable = await checkAgentAvailability(value.group);
if (agentAvailable) {
// Find and assign an agent
const agent = await findAvailableAgent(value.group);
await moinAI.assignAgent(conversationId, agent);
} else {
// Cancel handover or queue
await moinAI.cancelHandover(conversationId, 'no_agents_available');
}
}
if (type === 'message' && sender === 'user') {
// The user's own message, echoed back - forward it to the agent
await forwardToAgent(conversationId, req.body);
}
res.status(200).send('OK');
});
// Sending agent messages
async function sendAgentReply(conversationId, text, agent) {
await moinAI.sendAgentMessage(conversationId, {
message: { text },
agent: { name: agent.name }
});
}
# Handover Best Practices
Follow these best practices for a seamless handover experience:
# 1. Check Availability Before Handover
Always check agent availability before initiating a handover to provide appropriate user feedback:
// Before initiating handover
const availability = await moinAI.checkAgentAvailability(conversationId, group);
if (availability.available === false) {
// The bot has no live chat configuration - handover is not possible at all.
// Offer alternatives: leave a message, callback, try later
} else {
// available === null - ask your own system whether anyone can take it.
}
Note that this endpoint cannot see your agents. It reports whether the bot is set up for live chat; the availability decision stays with you.
# 2. Provide Queue Updates
Keep users informed while waiting:
- Update queue position every 30-60 seconds
- Show estimated wait time when possible
- Offer to leave a message if wait is too long
- Allow users to cancel and return to bot
# 3. Handle Edge Cases
Plan for these scenarios:
| Scenario | Recommended Action |
|---|---|
| Agent disconnects | Reassign to another agent or return to queue |
| User goes inactive | Send reminder, then timeout with option to resume |
| Long queue times | Offer callback or message option |
| Outside business hours | Inform user of hours, offer alternatives |
| No agents in group | Fall back to general queue or bot handling |
# 4. Provide Context to Agents
Include relevant information when assigning agents:
await moinAI.assignAgent(conversationId, {
agent: agentInfo,
context: {
userIntent: 'order_complaint',
sentiment: 'frustrated',
previousTopics: ['shipping', 'refund'],
customerTier: 'premium'
}
});
# 5. Use Typing Indicators
Make conversations feel natural:
// Agent is typing
await moinAI.sendTypingIndicator(conversationId, true, 'agent');
// Agent sends message
await moinAI.sendAgentMessage(conversationId, message);
// Stop typing
await moinAI.sendTypingIndicator(conversationId, false, 'agent');
# 6. End Handovers Gracefully
Always properly close handovers:
// When agent resolves the issue
await moinAI.endHandover(conversationId, 'resolved_by_agent');
// When transferring to another agent
await moinAI.endHandover(conversationId, 'transferred');
// Then start new handover with new agent
# 7. Track Handover Metrics
Monitor key metrics for optimization:
- Average wait time: Time from request to agent assignment
- Handover rate: Percentage of conversations requiring human help
- Resolution time: Time from assignment to resolution
- Abandon rate: Users who leave during queue
- Transfer rate: Conversations requiring multiple agents
# 8. Configure Appropriate Timeouts
Set reasonable timeouts for your use case:
| Timeout | Recommended Value | Description |
|---|---|---|
| Queue timeout | 5-10 minutes | Max time in queue before offering alternatives |
| Inactivity timeout | 3-5 minutes | Time without response before warning |
| Session timeout | 30-60 minutes | Max handover duration |
# Handover SDK Extensions
Extend your SDK client with handover-specific methods:
class MoinAIClient {
// ... base methods ...
// Handover methods
async initiateHandover(conversationId, options = {}) {
const { data } = await this.client.post(
`/conversations/${conversationId}/handover`,
options
);
return data;
}
async getHandoverStatus(conversationId) {
const { data } = await this.client.get(
`/conversations/${conversationId}/handover`
);
return data;
}
async setAgentPresence(agent, status) {
const { data } = await this.client.put('/agents/presence', { agent, status });
return data;
}
async claimConversation(conversationId, agent) {
const { data } = await this.client.post(
`/conversations/${conversationId}/handover/agent`,
{ agent }
);
return data;
}
// agentId must be the agent that claimed this conversation.
async sendAgentMessage(conversationId, text, agentId, requestId) {
const { data } = await this.client.post(
`/conversations/${conversationId}/handover/message`,
{ message: { text }, agent: { id: agentId }, requestId }
);
return data;
}
async sendAgentEvent(conversationId, kind, value, agentId) {
const { data } = await this.client.post(
`/conversations/${conversationId}/handover/event`,
{ kind, value, agent: { id: agentId } }
);
return data;
}
async endHandover(conversationId, reason) {
const { data } = await this.client.post(
`/conversations/${conversationId}/handover/end`,
{ reason }
);
return data;
}
async cancelHandover(conversationId, reason) {
const { data } = await this.client.post(
`/conversations/${conversationId}/handover/cancel`,
{ reason }
);
return data;
}
async checkAgentAvailability(conversationId, group) {
const { data } = await this.client.get(
`/conversations/${conversationId}/agents/availability`,
{ params: { group } }
);
return data;
}
async sendTypingIndicator(conversationId, typing, sender = 'agent') {
const { data } = await this.client.post(
`/conversations/${conversationId}/typing`,
{ typing, sender }
);
return data;
}
}
# Support
For technical support or questions, contact your Customer Success Manager or support@moin.ai.