# 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

  1. A moinAI bot configured in your account
  2. A Channel API connection on that bot. moinAI sets it up for you and hands you the botId, channelId, API key and webhook secret.
  3. 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 /conversations returns. Calling this endpoint immediately after creating a conversation can therefore return 409 CONVERSATION_NOT_READY. To set context up front, pass it to POST /conversations instead — 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 200 with { "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:

  1. 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.dumps adds spaces after : and , and writes ü as ü, so a single umlaut fails the check.
  2. Compare in constant time (crypto.timingSafeEqual, hmac.compare_digest), never with === or ==, which reveal through their timing how much of a guess was right.
  3. 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.

{
  "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 conversationId if 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.
  • userId does not identify a conversation. The same user can have any number of conversations, each with its own conversationId.

# 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/presence is what puts an agent online. Until an agent is online, GET /agents/availability reports unavailable and 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 unavailable rather 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:

  1. During requested or queued: User messages are stored and will be delivered to the agent when assigned
  2. 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

  1. Provide Regular Updates: Send queue position updates every 30-60 seconds
  2. Set Expectations: Show estimated wait time when available
  3. Offer Alternatives: Allow users to leave a message or callback request
  4. 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.