Technical docs
Custom Channel Integration
Overview
Use the private employee message API when an external chat system needs to send user messages to an Alloy AI teammate. This API is intended for custom corporate messengers, internal chat tools, support systems, or middleware that receives messages from another chat provider.
Base URL and auth
Base URL:
```text {api_url}/api/private/employee ```
Authenticate every request with your organization private API key. Send the key in one of these headers:
| Header | Format |
|---|---|
| `Authorization` | `Bearer {private_api_key}` |
| `api-key` | `{private_api_key}` |
Missing or invalid keys return `401` with `{ "error": "Unauthorized access" }`.
Message identity
Each request must include a `channel` value. Treat it as the stable identifier of your external chat system, for example `corporate-messenger`, `intranet-chat`, or `support-portal`.
For an ongoing chat, also send:
- `conversation.id`: the chat, room, thread, or dialog ID from your system.
- `contact.id`: the sender's user ID from your system.
Use the same `channel`, `conversation.id`, and `contact.id` on later requests so Alloy can continue the same chat history.
Send a message
```text POST /api/private/employee/{employee_id}/messages ```
Sends one user message to an AI teammate and waits for the AI response.
`{employee_id}` is the AI teammate ID.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
| `message` | string | yes | Non-empty user message text. |
| `channel` | string | yes | Stable identifier for your external chat system. |
| `attachments` | array | no | Defaults to `[]`. Each item carries base64 file data. |
| `conversation` | object | conditional | Required together with `contact` for a saved chat. Omit both for a one-off request. |
| `conversation.id` | string | yes, when `conversation` is present | Chat, room, thread, or dialog ID from your system. |
| `conversation.thread_id` | string or `null` | no | Optional subthread ID from your system. |
| `conversation.title` | string, empty string, or `null` | no | Optional display title for the chat. |
| `contact` | object | conditional | Required together with `conversation` for a saved chat. Omit both for a one-off request. |
| `contact.id` | string | yes, when `contact` is present | Sender's user ID from your system. |
| `contact.first_name` | string, empty string, or `null` | no | Sender first name. |
| `contact.last_name` | string, empty string, or `null` | no | Sender last name. |
`conversation` and `contact` are all-or-nothing. Sending only one of them returns `400`.
Attachment item
| Field | Type | Required | Notes |
|---|---|---|---|
| `name` | string | yes | Original file name. |
| `mime_type` | string | yes | MIME type. |
| `size` | integer | yes | File size in bytes. Must match decoded `data`. |
| `data` | string | yes | Base64 encoded file content. |
Attachments are saved only for saved chats, meaning requests that include both `conversation` and `contact`. If decoded file size does not match `size`, the request returns `400`.
Saved chat vs one-off request
Use a saved chat when your external system has a conversation that should remain visible and continue over time:
- include both `conversation` and `contact`
- Alloy saves the incoming message
- Alloy can reuse the same chat on later calls when you send the same identifiers
- the response includes the saved incoming `messageId`
Use a one-off request when you only need an immediate answer and do not want to save chat history:
- omit both `conversation` and `contact`
- Alloy returns an AI response for that single message
- the response has `messageId: null`
Response
```json { "success": true, "data": { "runId": "workflow-run-id", "message": "AI teammate response", "messageId": "saved-incoming-message-id-or-null" } } ```
| Field | Description |
|---|---|
| `data.runId` | ID of the processing run for this request. |
| `data.message` | AI teammate response. |
| `data.messageId` | ID of the saved incoming message, or `null` for one-off requests. |
Errors
| Status | Body | Meaning |
|---|---|---|
| `400` | `{ "error": "..." }` | Invalid request body or attachment size mismatch. |
| `401` | `{ "error": "Unauthorized access" }` | Missing or invalid private API key. |
| `404` | `{ "error": "AI employee not found" }` | The AI teammate ID does not exist in the authenticated organization. |
| `422` | `{ "error": "AI employee has no runnable workflow" }` | The AI teammate is not ready to process messages. |
Example: one-off request
```bash curl -X POST "{api_url}/api/private/employee/{employee_id}/messages" -H "Authorization: Bearer {private_api_key}" -H "Content-Type: application/json" -d '{ "channel": "corporate-messenger", "message": "What is our PTO policy?" }' ```
Example: saved chat
```bash curl -X POST "{api_url}/api/private/employee/{employee_id}/messages" -H "Authorization: Bearer {private_api_key}" -H "Content-Type: application/json" -d '{ "channel": "corporate-messenger", "message": "I need help with invoice 123", "conversation": { "id": "room-42", "thread_id": null, "title": "Finance support" }, "contact": { "id": "user-777", "first_name": "Alex", "last_name": "Customer" } }' ```
Example: saved chat with attachment
```bash curl -X POST "{api_url}/api/private/employee/{employee_id}/messages" -H "api-key: {private_api_key}" -H "Content-Type: application/json" -d '{ "channel": "corporate-messenger", "message": "Here is the screenshot", "conversation": { "id": "room-42" }, "contact": { "id": "user-777" }, "attachments": [ { "name": "screenshot.png", "mime_type": "image/png", "size": 20482, "data": "iVBORw0KGgo..." } ] }' ```
List messages for a saved chat
```text GET /api/private/employee/{employee_id}/messages ```
Returns saved messages for a conversation previously used with `POST`.
Query parameters
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
| `conversation_id` | string | yes | none | The same value you sent as `conversation.id`. |
| `channel` | string | yes | none | The same value you sent as `channel`. |
| `page` | integer | no | `1` | Minimum `1`. |
| `limit` | integer | no | `30` | Minimum `1`, maximum `50`. |
Response
```json { "success": true, "data": [ { "id": "message-id", "conversationId": "conversation-id", "threadId": null, "content": "I need help with invoice 123", "type": "text", "sender": "customer", "status": "sent", "timestamp": 1780000000000 } ], "pagination": { "page": 1, "limit": 30, "total": 1, "totalPages": 1, "has_more": false } } ```
Message objects can include additional fields such as attachments, reply references, reaction state, sender display name, and timestamps.
Errors
| Status | Body | Meaning |
|---|---|---|
| `400` | `{ "error": "..." }` | Missing or invalid query parameters. |
| `401` | `{ "error": "Unauthorized access" }` | Missing or invalid private API key. |
| `404` | `{ "error": "Conversation not found" }` | No saved chat exists for that `channel` and `conversation_id`. |
Example
```bash curl "{api_url}/api/private/employee/{employee_id}/messages?channel=corporate-messenger&conversation_id=room-42&page=1&limit=30" -H "api-key: {private_api_key}" ```
Integration pattern
A custom messenger bridge usually does this:
- Receive a user message from the external chat system.
- Convert the source chat system name to a stable `channel` value.
- Convert the external chat ID to `conversation.id`.
- Convert the external sender ID and name to `contact`.
- Call `POST /api/private/employee/{employee_id}/messages`.
- Send `data.message` from the Alloy response back to the external chat system.
- Use `GET /api/private/employee/{employee_id}/messages` only when your integration needs to reload saved history.
This API is synchronous. The response body is the delivery path back to the external chat system; this route does not register an outbound webhook for custom channels.