> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.ada.cx/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.ada.cx/_mcp/server.

# Creating a Custom Channel

This guide will take you on a hands-on journey through Ada's Conversations API, showing you exactly how to build, run, and understand a custom channel integration from start to finish.

If you've already reviewed the [Getting started](/reference/conversations/getting-started) guide, you'll notice this goes much deeper. Here, we'll not only show you what to do, but explain why each step matters, what's happening under the hood, and how to extend it into production.

> **Tip**
>
> Some examples in this guide are taken from our official [Conversations API demo repository](https://github.com/AdaSupport/ada-conversations-api-demo), while others were created to illustrate alternative approaches.

#### Rate limits and retries

Ada's APIs include rate limits to ensure consistent performance and reliability. You're unlikely to hit these during normal development, but it's good practice to handle `HTTP 429 Too Many Requests` responses and implement retry logic. For more details, see:

* [Conversations API rate limits](/reference/conversations/overview#rate-limits)
* [Global rate limits](/reference/introduction/limits)

## Setting up

Before we dive into the code, let's set up everything you need to run our [demo](https://github.com/AdaSupport/ada-conversations-api-demo) locally and connect your environment to Ada's Conversations API. This ensures your local app can authenticate with Ada, create conversations, and receive webhook events in real time, just like a production integration would.

#### Obtain an Ada API key

If you don't already have one, [generate a new API key](/reference/introduction/authentication) in the Ada Dashboard.
This key lets your integration securely communicate with Ada's Conversations API.

#### Clone the demo repo

Our [demo repository](https://github.com/AdaSupport/ada-conversations-api-demo) contains a working example of how to connect to the Conversations API, send and receive messages, and handle webhook events. You'll use it both as a reference and a sandbox for testing your integration.

* Run these commands in your Terminal:

  **`bash`**

  ```bash title="bash"
  git clone https://github.com/AdaSupport/ada-conversations-api-demo.git
  cd ada-conversations-api-demo
  ```

  This will:

  * Download the full demo project to your local machine.
  * Change into the project directory so you can start working inside it.

#### What's inside the repo?

Once cloned, take a moment to look around the folder structure. The key files and directories you'll work with are:

```
├── app/
│   ├── ada_api.py          # Handles API calls to Ada (create conversation, send messages, end chat)
│   ├── webhooks.py         # Defines webhook endpoints and verification logic
│   ├── webpage/index.py    # Simple local chat UI for testing messages
│   └── server/             # Entry point for running the FastAPI server
├── .env.example            # Template for your environment variables
├── requirements.txt        # Python dependencies (aiohttp, FastAPI, svix, etc.)
└── README.md               # Short overview of the demo
```

#### How it works

This demo is built around a simple event flow:

1. A user sends a message through the local UI or via API call.
2. The backend (in `ada_api.py`) forwards that message to Ada’s Conversations API.
3. Ada responds asynchronously via a webhook (handled in `webhooks.py`).
4. The message gets rendered back into the local chat interface or forwarded to another platform like Slack.

#### Behind the scenes

This repo uses [FastAPI](https://fastapi.tiangolo.com/) for its local web server, [aiohttp](https://docs.aiohttp.org/en/stable/) for async API calls, and [Svix](https://www.svix.com/) for webhook verification. In production, you'll likely split these pieces across your own services, but the demo keeps it all in one place for simplicity, making it perfect for learning the flow end to end.

#### Create a tunnel for Ada's webhooks

> **Info**
>
> **Important**: This step applies to **local testing only**.  In production, your webhook endpoint should be hosted on a publicly accessible, HTTPS-secured domain that Ada can reach directly.

Ada sends all AI Agent responses and conversation updates as webhooks, HTTP callbacks that your integration needs to receive in real time.

Because your computer isn't publicly accessible during local development, Ada can't reach `localhost` directly. To bridge that gap, you can use a tunneling tool. One option is [**ngrok**](https://ngrok.com/), which creates a secure, temporary public URL that forwards requests to your local server.

> **Tip**
>
> There are other ways to achieve the same result, such as [Cloudflare Tunnels](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/), [Localtunnel](https://theboroer.github.io/localtunnel-www/), or even network port forwarding. For simplicity, let's assume you're using ngrok.

#### Run ngrok

Run this in a new terminal window:

**`bash`**

```bash title="bash"
ngrok http 8080
```

This creates a secure public URL that forwards requests to your local FastAPI server running on port `8080`. If everything is working as expected, you will see a newly created forwarding URL, for example: `https://1234-56-78-90.ngrok-free.app`.

That's the public address Ada will use to send webhook requests to your local app.

#### Keep ngrok running

Keep this terminal window open while you’re testing. If you close it, your tunnel (and webhook connectivity) will stop.

If you restart ngrok, it will generate a new URL. You’ll need to update the endpoint in the Ada Dashboard whenever that happens.

> **Tip**
>
> [Later in this guide](#webhook-endpoint-name), you'll use this forwarding address when you create a webhook endpoint in the Ada Dashboard (under **Config > PLATFORM > Webhooks**). For example, your full webhook endpoint URL might look like this: `https://1234-56-78-90.ngrok-free.app/webhooks/message`.

#### Configure Conversations API webhooks

Ada delivers AI Agent responses and conversation events to your integration through webhooks. These are secure HTTP callbacks that notify your app when something happens in Ada, such as a new message or a conversation ending.

To protect your integration, you need a way to ensure those webhook requests really come from Ada and not from another source trying to mimic them. Ada provides a Signing Secret for each webhook endpoint, which your service can use to verify the authenticity of every incoming request.

#### Add a webhook endpoint

1. In the Ada Dashboard, go to **Config > PLATFORM > Webhooks > Endpoints**.

2) [Create a new endpoint](/reference/webhooks/overview#adding-an-endpoint-via-dashboard), or open an existing one if you already have one configured.

#### Set your endpoint URL

* Make sure the endpoint points to either the temporary public URL that forwards requests to your [local server](#local-tunnel) (via ngrok if you're testing locally) or to your production webhook URL that matches the route your server listens on.

  For example: `https://1234-56-78-90.ngrok-free.app/webhooks/message`.

  This URL corresponds to the `/webhooks/message` route defined in your demo app (`app/webhooks.py`).

#### Subscribe to Conversations API events

* On the **Endpoints** tab, under **Subscribe to events**, make sure to include the Conversations API events. You'll find them under the **v1 > conversation** category:

  * `v1.conversation.message`: Triggers when a message is sent or received.
  * `v1.conversation.created`: Triggers when a new conversation starts.
  * `v1.conversation.ended`: Triggers when a conversation closes.

  These events ensure your integration receives real-time updates for every conversation and message handled by Ada.

#### Obtain the Signing Secret

1. In the right-side navigation panel, locate the **Signing Secret**.
2. Reveal and copy the value. You will need it in the [next step](#webhook-secret) when updating your `.env` file: `WEBHOOK_SECRET=<your-signing-secret>`.
   Your integration will use this secret to verify that all incoming webhook requests originate from Ada.

#### Set up your \`.env\` file

The `.env` file is where you'll store the core configuration values that connect your local demo to your Ada instance. Think of it as the bridge between your code and the Ada platform: it tells your local environment which AI Agent to talk to, how to authenticate, what channel to use, and how to verify incoming webhook requests.

#### Locate and duplicate the template

In the demo repository, you'll find a template called `.env.example`. This file includes all the configuration keys you’ll need. Start by duplicating it so you can edit your own version:

**`bash`**

```bash title="bash"
  cp .env.example .env
```

#### Open and review the file

Now open your newly created `.env` file in your editor. It will look something like this:

**`bash`**

```bash title="bash"
  ADA_BASE_URL=
  ADA_API_KEY=
  ADA_CHANNEL_ID=
  WEBHOOK_SECRET=
```

#### Start updating your .env file

Here's what each of these values means and how to update them:

* `ADA_BASE_URL`: The base URL for your Ada instance consisting of your agent's handle and your organization's domain. For example: `ADA_BASE_URL=https://example.ada.support`.

- `ADA_API_KEY`: The [Ada API key](#api-key) you generated in your Ada Dashboard under **Config > PLATFORM > API keys**. This authenticates every API request to Ada. Treat it like a password: never commit it to Git.

* `ADA_CHANNEL_ID`:The unique ID of the custom channel your app will use to create and manage conversations. You don't have this yet. We'll create the channel in [Step 1](#step-1-create-a-custom-channel), then come back to this file and fill it in. For now, leave it blank: `ADA_CHANNEL_ID=`.

- `WEBHOOK_SECRET`: The signing secret Ada uses to verify webhook requests. Use the value obtained from the webhook endpoint you created in the [previous step](#signing-secret) in the Ada Dashboard (**Config > PLATFORM > Webhooks > Endpoints > Signing Secret**).

#### Sample .env file

After you've filled in the available values, your `.env` file should look something like this:

**`bash`**

```bash title="bash"
ADA_BASE_URL=https://example.ada.support
ADA_API_KEY=abcd1234efgh5678ijkl
ADA_CHANNEL_ID=
WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxx
```

## Step 1: Create a custom channel

A custom channel is the connection point between Ada and your integration. It tells Ada where conversations will take place and how messages should flow between your AI Agent and your external system. In other words, you're creating a bridge that lets Ada talk to your app.

Create your custom channel using the [Create a new channel](./../create-channel) endpoint.

#### When to create a custom channel

You only need to create a custom channel once per integration, not every time your service runs. Typically, this happens during initial setup or deployment, when you're wiring up Ada to a new platform or system.

If you're connecting Ada to multiple external systems (for example, Slack, SMS, or a web chat widget), you'll create a separate channel for each integration.

Once a channel is created, you'll reuse its unique channel ID in all future API calls that reference this integration. For example, when starting conversations or sending messages.

#### What to include in the request

To create a channel, call the [Create a new channel](./../create-channel) endpoint and include the applicable fields in the [request](./../create-channel/#request). At a minimum, you'll need to provide a `name`, `description`, and `modality` for your new channel.

#### Sample request

This example shows the minimal HTTP request needed to create a new custom channel in Ada using the Conversations API.

Replace `<handle>` with your Ada handle (the same [subdomain](#handle) defined in your `.env` file). In the `Authorization` heade, include `<your-api-key>` that you [created earlier](#api-key) to authenticate your request.

> **Tip**
>
> Keep the channel's `name` and `description` clear and unique. They'll help you identify this integration later, especially if you manage multiple channels.

**`http`**

```http title="http"
  POST https://<handle>.ada.support/api/v2/channels
  Authorization: Bearer <your-api-key>
  Content-Type: application/json

  {
    "name": "My Custom Channel",
    "description": "A custom messaging channel for my AI Agent",
    "modality": "messaging",
    "metadata":{
      "webpage_host": "https://lovelace-chat.com"
    }
  }
```

#### See what Ada returns

If the call is successful, Ada returns a JSON response with the details of your new channel. Here's the field that matters most for this step:

#### Sample response

**`json`**

```json title="json"
  {
    ...
    "id": "68f25a35072ad87710c1d96b",
    ...
  }
```

> **Example abbreviated for clarity. See the full response [here](./../create-channel#response).**

This `id` is your channel ID. You'll need it for all future Conversations API calls. Once added to your configuration, your app will automatically use this channel for every conversation and message it creates.

#### Declare channel capabilities (optional)

A channel's `capabilities` field declares what its surface can render, and the AI Agent tailors its replies to match. `markdown` is the only capability available today.

With `markdown` enabled, the Agent is instructed to write replies using Markdown — bold, italics, and inline links — the same instruction it follows on chat. With it disabled, the Agent writes the same answer as plain prose.

Capabilities are disabled by default, so an existing channel's replies do not change until you opt in.

#### Enable a capability when creating a channel

Include the `capabilities` object in the same [Create a new channel](./../create-channel) request shown above:

**`http`**

```http title="http"
  POST https://<handle>.ada.support/api/v2/channels
  Authorization: Bearer <your-api-key>
  Content-Type: application/json

  {
    "name": "My Custom Channel",
    "description": "A custom messaging channel for my AI Agent",
    "modality": "messaging",
    "capabilities": {
      "markdown": true
    }
  }
```

A channel created without `capabilities` defaults to `"markdown": false`.

#### Change a capability on an existing channel

Recreating the channel is not necessary. Use the [Update a channel](./../update-channel) endpoint, which accepts only the `capabilities` field:

**`http`**

```http title="http"
  PATCH https://<handle>.ada.support/api/v2/channels/<channel_id>
  Authorization: Bearer <your-api-key>
  Content-Type: application/json

  {
    "capabilities": {
      "markdown": true
    }
  }
```

The request returns the full updated channel. Behavior notes:

* `capabilities` is required. A request without it returns `400 Bad Request`.
* Capabilities you omit keep their current value; only the fields you send are changed.
* `"markdown": false` returns the channel to plain-prose replies. A conversation captures the channel's capabilities when it is created, so a change applies to conversations started after it. Conversations already open keep the setting they began with.
* Only custom channels can be updated. A native channel ID returns `404 Not Found`.

#### Comparing replies with markdown enabled and disabled

The Agent addresses the same question either way. What changes is how it writes that answer into the `content.body` your webhook receives.

With `capabilities.markdown` disabled (the default):

**`json`**

```json title="json"
  "content": {
    "type": "text",
    "body": "To reset your password, go to Settings, then Security, then select Reset password. Full steps here: https://help.example.com/reset"
  }
```

With `capabilities.markdown` enabled:

**`json`**

```json title="json"
  "content": {
    "type": "text",
    "body": "To reset your password, go to **Settings > Security** and select **Reset password**. See the [full steps](https://help.example.com/reset) for details."
  }
```

Because the reply is generated under a different instruction rather than reformatted afterward, the two bodies are not the same text with markup added or removed. Wording and sentence structure can differ.

#### Important notes

* Enable `markdown` only if your channel's UI renders it. If it does not, end users see the raw syntax (`**bold**`, `[link](url)`) in their messages. See [Design considerations](#design-considerations) in Step 6 for what your integration needs to do.
* A reply that begins with a Markdown link can be delivered as a `link` message rather than a `text` one, so handle both `content.type` values.
* As a best practice, treat `content.body` as untrusted input when rendering Markdown. See [Design considerations](#design-considerations) in Step 6.
* Native channels expose `capabilities` as read-only, derived from the surface: chat and email report `"markdown": true`; voice and social channels (WhatsApp, Facebook Messenger, Instagram, SMS) report `"markdown": false`.
* `markdown` is the only capability today. Others may be added to the `capabilities` object over time, so treat unrecognized fields as forward-compatible rather than an error.

#### Relay text messages with your own SMS provider

A channel with the `sms` modality carries the text messages your Voice AI Agent sends during a call. Your relay sends each one through your own SMS provider, and the caller's reply comes back to Ada through the same relay. See [Relaying voice texts through your own SMS provider](/reference/conversations/developer-guides/relaying-voice-texts-through-your-own-sms-provider). A public [reference relay](https://github.com/AdaSupport/ada-sms-relay) shows the whole integration end to end.

#### Finalize your .env file

Now that you've created your custom channel and received its ID, you can complete your [`.env` configuration](#set-up-your-env-file).

Open the file and update the `ADA_CHANNEL_ID` value using the `id` returned in Ada's response. For example: `ADA_CHANNEL_ID=68f25a35072ad87710c1d96b`.

Once saved, your app will automatically use this channel for all conversations and messages it creates.

#### Sample .env file

Your final `.env` file should now include all the required values:

**`bash`**

```bash title="bash"
  ADA_BASE_URL=https://example.ada.support
  ADA_API_KEY=abcd1234efgh5678ijkl
  ADA_CHANNEL_ID=68f25a35072ad87710c1d96b
  WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxx
```

## Step 2: Start a conversation

Before you can send or receive messages, you need a conversation in Ada. A conversation acts as the container that holds context — who the user is, what channel they're on, their current state, and any relevant metadata.

Start a conversation using the [Create a new conversation](./../create-conversation) endpoint. When you do that, Ada automatically sends a [Greeting](/docs/automation/greetings) message to the user. This initial message is triggered by Ada's platform APIs each time a conversation is created, even if no user message has been received yet.

#### When to start a conversation

Start a new conversation when:

* You receive the first message from a user on your custom channel and no active conversation exists yet.
* You want to proactively start a thread or outreach message on behalf of a user.

Each new conversation in Ada establishes the context for all messages that follow. After a conversation starts, all messages from the user, Ada, or a human agent are linked to that conversation ID.

#### What to include in the request

For this step, you'll only need to include the following fields when creating a conversation:

* `channel_id` (required): The custom channel where this conversation will live (from [Step 1](#step-1-create-a-custom-channel)).
* `end_user_id` (optional): The ID of a user that already exists in Ada.
  * If you don't pass it, Ada will create a new end user and return the newly created `end_user_id` in the response.
  * If you do pass it, Ada will associate the conversation with that user.
* **Identifying end users with your own identifier (optional):** If your system tracks end users by its own stable identifier (for example a CRM contact ID or a phone number), you can use the `external_id` field on the End Users API to look up or create end users by that value instead of tracking Ada's `end_user_id` separately.
  * Call [`GET /v2/end-users/?external_id=<value>`](./../../end-users/get-end-users) to retrieve an existing end user — a `200` response returns the `end_user_id` to pass to `POST /v2/conversations/`.
  * If the lookup returns `404`, call [`POST /v2/end-users/`](./../../end-users/create-end-user) with the same `external_id`. The call is idempotent: it returns the existing user (`200`) if the identifier is already mapped, or creates a new one (`201`).
  * See [Reuse end users across conversations with external\_id](./../../end-users/getting-started#reuse-end-users-across-conversations-with-external_id) for the full pattern.

For details on all supported parameters, see the endpoint [request](./../create-conversation#request) fields.

#### Sample request

This example shows the minimal HTTP request needed to start a new conversation. It includes only the required fields and uses standard authentication headers.

Replace `<handle>`, `<your-api-key>`, `<end_user_id>`, and `<your-channel-id>` with your actual values. The `end_user_id` field is optional. If omitted, Ada automatically creates a new end user and returns the ID in the response.

**`http`**

```http title="http"
  POST https://<handle>.ada.support/api/v2/conversations
  Authorization: Bearer <your-api-key>
  Content-Type: application/json

  {
    "channel_id": "<your-channel-id>",
    "end_user_id": "<optional-user-id>"
  }
```

#### Code example

This example uses the standard Python `requests` library to create a conversation synchronously. It's the simplest approach: ideal for quick tests, scripts, or integrations where you don't need asynchronous I/O.

The call includes the required `channel_id` and, optionally, an `end_user_id` if you want to associate the conversation with a specific user.

**`python`**

```python title="python"
import requests

request_body = {"channel_id": ADA_CHANNEL_ID}
if user_id:
    request_body["end_user_id"] = user_id

response = requests.post(
    f"{ADA_BASE_URL}/api/v2/conversations",
    headers={"Authorization": f"Bearer {ADA_API_KEY}"},
    json=request_body,
)
```

#### See what Ada returns

If your request succeeds, Ada responds with a JSON payload that includes details about the new conversation.

Here are the key fields you'll need to keep track of:

#### Sample response

**`json`**

```json title=json
  {
    "id": "5df263b7db5a7e6ea03fae9b",
    "end_user_id": "5df263b7db5a7e6ea03fae9c",
    ...
  }
```

> **Example abbreviated for clarity. See the full response [here](./../create-conversation#response).**

* `id`: The conversation ID. You'll use this to send messages and end the conversation.
* `end_user_id`: Identifies the end user participating in the conversation. You'll need this when sending messages as that user.

Keep both values stored in your system. They're required for all subsequent API calls, such as sending user messages or ending the conversation.

## Step 3: Send messages

Once a conversation is active, your app needs to send user messages to Ada's AI Agent. This is how you transmit what the user types in your channel so Ada can process it and respond. Each message is tied to a specific conversation (identified by `conversation_id`) and a specific user (`end_user_id`).

You can send end users' messages using the [Create a new message](./../create-message) endpoint.

#### When to send a message

Send a message whenever your integration receives input from the user. For example, a chat message, a Slack thread reply, or a mobile text.

Each message you send must belong to an active conversation.

You'll reference the `conversation_id` from [Step 2](#step-2-start-a-conversation) and the `end_user_id` associated with that conversation.

If the conversation has already ended, start a new one before sending more messages.

> **Tip**
>
> If your integration supports multiple users or threads, keep track of the `conversation_id` and `end_user_id` together so you can easily route messages to the right Ada conversation later.

#### What to include in the request

Each message must tell Ada who sent it and what was said.
At minimum, include the following fields in your request body:

* `conversation_id`: The ID of the conversation.
* `author`: The person sending the message, with their role set to `end_user`.
* `content`: The message body.

Optional fields such as `display_name`, `avatar`, or additional metadata are described in the [endpoint request fields](./../create-message#request).

#### Sample request

This example shows the minimal HTTP request required to send a user message to Ada within an active conversation and trigger a response from the AI Agent. Replace `<handle>`, `<conversation_id>`, `<your-api-key>`, and `<end_user_id>` with your actual values.

**`http`**

```http title="http"
  POST https://<handle>.ada.support/api/v2/conversations/<conversation_id>/messages
  Authorization: Bearer <your-api-key>
  Content-Type: application/json

  {
    "author": {
      "role": "end_user",
      "id": "<end_user_id>"
    },
    "content": {
      "type": "text",
      "body": "I need help with my order."
    }
  }
```

#### Code example

This example uses the standard Python `requests` library and is ideal for quick tests or simple applications where you don’t need concurrency.

**`python`**

```python title="python"
  import requests

  response = requests.post(
      f"{ADA_BASE_URL}/api/v2/conversations/{conversation_id}/messages",
      headers={"Authorization": f"Bearer {ADA_API_KEY}"},
      json={
          "author": {
              "role": "end_user",
              "display_name": display_name,
              "id": user_id,
              "avatar": avatar,
          },
          "content": {"type": "text", "body": text},
      },
  )

  response.raise_for_status()
```

#### See what Ada returns

When your request succeeds, Ada returns a JSON response with details about the new message. Here are the fields that matter most for this step:

#### Sample response

**`json`**

```json title="json"
  {
    "id": "6789abcd1234ef567890",
    "conversation_id": "5df263b7db5a7e6ea03fae9b",
    ...
  }
```

> **Example abbreviated for clarity. See the full response [here](./../create-message#response).**

* `conversation_id`: Identifies the conversation that this message belongs to. You'll use it for all follow-up actions, such as sending additional messages or ending the conversation.
* `id`: Identifies this specific message. You can use it for logging or debugging if you need to trace individual messages.

Once the message is processed, Ada sends the AI Agent’s response back to your integration as a [v1.conversation.message](./conversation-message-webhook) webhook event.

Your integration should listen for that event (using your webhook endpoint) and display the AI Agent's reply in your channel or UI.

## Step 4: Listen on responses via webhooks

Once your app starts sending messages to Ada, you need a way to listen for Ada's responses. Ada delivers AI Agent messages, conversation updates, and events as webhooks. A webhook is a secure HTTP callback that notifies your integration in real time when something happens.

When Ada sends a webhook, it's Ada's way of saying: *Here's something new that happened — a message, an event, or an update!*

Your integration listens for these events, verifies that they came from Ada, and responds accordingly, for example, by displaying the AI Agent's reply in your channel or forwarding it to another system like Slack.

To receive these webhook events, your app needs to [expose a publicly accessible endpoint](#set-your-endpoint-url) that Ada can post these webhook events to.

* In local development, this endpoint is usually made accessible through a tunneling tool such as ngrok (for example, `https://1234-56-78-90.ngrok-free.app/webhooks/message`).
* In production, it should live on a public, HTTPS-secured domain (for example, `https://api.yourapp.com/webhooks/message`).

#### When Ada sends webhooks

Ada triggers webhooks whenever events occur in a conversation, such as:

* When the AI Agent sends a message (`v1.conversation.message`)
* When a conversation starts (`v1.conversation.created`)
* When a conversation ends (`v1.conversation.ended`)

Your app can [subscribe to these events](#subscribe-to-conversations-api-events) in the **Webhooks > Endpoints** section of the Ada Dashboard. Once subscribed, Ada will POST event payloads to your configured webhook URL.

> **Tip**
>
> If your AI Agent fans out to multiple custom channels, you can scope each endpoint to a single channel using [channel filtering](/reference/webhooks/overview#channel-filtering). The events emitted from a custom channel are tagged with the channel's ID, so an endpoint with that ID in its **Channels** field receives only events for that channel.

#### What to expect in the webhook payload

Each webhook event includes a JSON payload that represents the event type and associated data.

#### Sample webhook (AI Agent message)

**`json`**

```json title="json"
  {
    "type": "v1.conversation.message",
    "timestamp": "2025-10-21T12:15:00+00:00",
    "data": {
      "conversation_id": "5df263b7db5a7e6ea03fae9b",
      "author": {
        "role": "ai_agent",
        "display_name": "Ada"
      },
      "content": {
        "type": "text",
        "body": "Sure! You can check your order by clicking the link below."
      }
    }
  }
```

When your app receives this webhook, it can take the message text (`content.body`) and display it to the user, send it to a connected channel (like Slack), or log it for analytics.

#### How to handle and verify webhooks

To receive and process webhooks, your app must define an endpoint that matches the URL configured in the Ada Dashboard. This route handles incoming POST requests, verifies that they're from Ada, and parses the message data.

* If you want to use a language-specific package, you can use the package provided by Svix as documented [here](https://docs.svix.com/receiving/verifying-payloads/how).

* Alternatively, webhooks can be verified without their library using this [manual verification guide](https://docs.svix.com/receiving/verifying-payloads/how-manual).

For more information about how Ada uses webhooks, see [this topic](/reference/introduction/webhooks).

#### Code example

The following example shows a sample webhook handler from our [demo repository](https://github.com/AdaSupport/ada-conversations-api-demo), located in `app/webhooks.py`.

**`python`**

```python title="python"
  from fastapi import Request, HTTPException
  from svix import Webhook, WebhookVerificationError

  @app.post("/webhooks/message")
  async def handle_message(request: Request):
      headers = request.headers
      payload = await request.body()

      try:
          # Verify that the request really came from Ada using your webhook signing secret
          webhook = Webhook(WEBHOOK_SECRET)
          webhook.verify(payload, dict(headers))
      except WebhookVerificationError:
          raise HTTPException(status_code=400, detail="Invalid signature")

      msg = json.loads(payload)
      process_message(msg)
      return {"status": "ok"}
```

> **Tip**
>
> Ada uses Svix to sign all webhook requests. You must verify the signature using your [`WEBHOOK_SECRET`](#webhook-secret) (from the Ada Dashboard) before trusting the data.

## Step 5: Buffer and order messages

Ada delivers each message as its own webhook. Network jitter and parallel processing can prevent those webhooks from arriving in chronological order. The fix is simple: buffer briefly, sort by timestamp, then render.

> **Tip**
>
> In a production environment, you'll likely have multiple web servers. Use a shared store/queue so ordering works across instances.

#### How it works in the demo

In [our demo repository](https://github.com/AdaSupport/ada-conversations-api-demo), incoming webhook messages are received, sorted, and rendered in order to keep the chat experience real-time and conversational.

1. Webhooks are received one by one.
2. Each message is stored in a short-lived, in-memory queue per conversation.
3. A brief delay (e.g., 1–2 seconds) allows messages to accumulate into a micro-batch.
4. The batch is then sorted by timestamp.
5. Messages are displayed in the UI in the correct order.

This pattern preserves the conversational flow while still feeling real-time.

#### Code example

The demo implements a simple in-memory batcher using `asyncio`. You can find this logic in `app/webhooks.py`.

**`python`**

```python title="python"
  # app/webhooks.py (excerpt)

  _global_msg_queue = []
  _global_batch_task = None
  _global_batch_lock = asyncio.Lock()

  async def push_message_to_queue(msg):
      """Batch messages in a queue to be processed after a delay to account for unordered messages"""
      global _global_batch_lock

      async with _global_batch_lock:
          global _global_msg_queue, _global_batch_task
          _global_msg_queue.append(msg)

          if _global_batch_task is not None:
              _global_batch_task.cancel()

          _global_batch_task = asyncio.create_task(batch_process_messages())

  async def batch_process_messages():
      """Process all messages in the queue after a delay"""
      global _global_batch_lock

      await asyncio.sleep(2)

      async with _global_batch_lock:
          global _global_msg_queue, _global_batch_task
          messages = _global_msg_queue
          _global_msg_queue = []
          _global_batch_task = None

      messages.sort(key=lambda m: m.timestamp)
      for msg in messages:
          push_message_to_chat(
              msg.data.conversation_id,
              msg.data.author.id,
              msg.data.author.role,
              msg.data.content,
              msg.data.author.display_name,
              msg.data.author.avatar,
          )
```

#### What's happening here

* `push_message_to_queue()` collects incoming webhook messages in a global queue.
* When new messages arrive, any pending batch task is canceled and rescheduled to include the latest messages.
* After a short delay, `batch_process_messages()` runs, sorting messages by timestamp and sending them to the chat UI for rendering.
* This lightweight batching logic ensures that even if webhook events arrive out of order, messages are displayed in sequence for a smooth, real-time conversation experience.

#### If you're setting up Slack

When flushing a batch to Slack:

* Use the channel and thread identifier (`thread_ts`) associated with the Ada conversation `id`.
* Post in order after sorting.
* For user/AI Agent rendering, map Ada's `author.role` to Slack's display (e.g., different user name/icon for `ai_agent` vs. `end_user`).

## Step 6: Render messages in your UI

Now that your integration can send and receive messages, the next step is to display the conversation in your UI or channel so that users see both their own messages and Ada’s responses in real time.

Your integration's job here is to take the incoming messages (from webhooks) and render them in order, with clear distinction between:

* End-user messages (what the user says)
* AI Agent messages (Ada's responses)
* Human agent messages (if your system supports [Handoffs](/docs/handoffs))

#### How it works in the demo

In [our demo repository](https://github.com/AdaSupport/ada-conversations-api-demo), incoming webhook messages are processed and displayed in the local chat UI:

1. The webhook payload is received and verified.
2. Message details (author, content, role, and so on) are passed to the UI handler.
3. The handler identifies the correct chat instance, converts the message to a UI element, and renders it in real time.

This flow keeps the chat interface responsive and aligned with incoming webhook events.

#### Code example

In the demo, the following function adds verified messages to the chat UI. You can find this logic in `app/webpage/index.py`.

**`python`**

```python title="python"
  def push_message_to_chat(
      conversation_id: str,
      user_id: str | None,
      role: str,
      content: MessageContent,
      display_name: str | None = None,
      avatar: str | None = None,
  ):
      """Convert a message from Ada's webhook into one displayed in the chat UI."""

      chat_ui = get_chat_ui(conversation_id)
      if not chat_ui or user_id == chat_ui.active_end_user_id:
          return

      chat_ui.add_message(user_id, role, content, display_name, avatar)
```

#### What's happening here

* `push_message_to_chat` takes the message payload from the webhook.
* `role` (e.g., `end_user`, `ai_agent`, `human_agent`) determines who the message is from.
* `chat_ui.add_message()` renders the text, avatar, and display name in the browser window.
* The UI auto-scrolls to the newest message so the conversation feels natural.

#### Design considerations

* Differentiate roles visually:
  * Show the user's messages aligned right (e.g., blue bubble).
  * Show Ada's messages aligned left (e.g., gray bubble, Ada avatar).
  * If you support handoffs, render human agent messages in a distinct color or include the agent's name.
* Show metadata when useful:
  * Timestamp each message.
  * Optionally show *Delivered* or *Seen* indicators.
  * Use `display_name` and `avatar` fields from the payload for personalization.
* Handle link messages:
  * Some messages (for example, CSAT surveys or links) use `content.type: "link"`.
  * Render these as clickable links or buttons rather than plain text.
* Render Markdown if your channel opted in:
  * If your channel declares [`capabilities.markdown`](#declare-channel-capabilities), replies arrive with Markdown syntax in `content.body`. Render the formatting (bold, italics, links) instead of displaying the raw characters.
  * Markdown replies can also arrive as `content.type: "link"` when a message begins with a link, so keep the link handling above in place.
* Graceful endings:
  * When you receive a `v1.conversation.ended` event, disable inputs and show a *Conversation closed* notice.

> **Tip**
>
> As a best practice, treat `content.body` as untrusted input, like any generated text: disable raw-HTML passthrough in your Markdown renderer (`html: false`, no `rehype-raw`), sanitize the result before inserting it into the DOM, and allow only `http`, `https`, and `mailto` link schemes.

#### Rendering messages in other channels

If you're not using a browser UI (for example, you’re integrating with Slack, Teams, or SMS), the same webhook data can be sent to those platforms instead of your UI:

* **Slack**: Use the `chat.postMessage` API to post Ada's responses in the correct thread (matching the stored `conversation_id` to `thread_ts` mapping).
* **SMS**: Send Ada's `content.body` to your messaging provider's API.

#### If you're setting up Slack

When Ada sends a webhook containing the AI Agent’s response, your app needs to post that message back to Slack in the correct channel or thread.

#### Sample code

**`python`**

```python title="python"
  response = slack_client.chat_postMessage(
      channel=slack_channel_id,
      text=ada_message_text,
      thread_ts=slack_thread_ts
  )
```

Use your stored mapping between Ada’s conversation `id` and Slack's `channel/thread_ts` to ensure replies appear in the right thread. This keeps the full conversation—both user messages and Ada’s responses—neatly organized within the same Slack thread.

## Step 7: End a conversation

Once a conversation has run its course, you can close it using the [End a conversation](./../end-conversation) endpoint. Ending a conversation signals to Ada that no further messages will be exchanged. This ensures that sessions are tracked, reported, and summarized correctly.

#### When to end a conversation

Not every channel will have an explicit **End chat** control, but many include one (for example, a **Close** or **End conversation** button). In your custom channel, you can call the [End a conversation](./../end-conversation) endpoint when:

* The end user clicks an **End Chat** or equivalent UI action.
* The system detects inactivity or a timeout.
* Your integration's workflow determines the chat should close automatically (for example, after a successful resolution).

After a conversation ends:

* No further messages can be sent to that conversation ID.
* Ada may send a follow-up webhook, such as a CSAT (customer satisfaction) survey link, depending on the AI Agent's configuration.

#### About CSAT surveys

A [Customer Satisfaction (CSAT) survey](/docs/optimization/performance/csat-survey) lets end users rate their experience or leave comments after interacting with your AI Agent. The feedback helps you measure satisfaction, spot improvement opportunities, and track your AI Agent's performance.

#### When to trigger them

A CSAT survey is typically sent right after the conversation is closed, either:

* When your integration calls the [End a conversation](./../end-conversation) endpoint,
* When the conversation ends automatically based on your Agent's settings (for example, after a period of inactivity or when a workflow rule closes it),
* After a handoff completes with a human agent.

Both AI Agent CSAT and Human Agent CSAT surveys can be enabled and managed in your AI Agent settings.

The CSAT survey is sent as a webhook event (`v1.conversation.message`) with a `link` message [type](./../conversation-message-webhook#payload.body.data.content). This allows your integration to display the survey link in your custom channel, such as Slack or a web chat.

#### How to handle surveys

Listen for the CSAT webhook event just like any other message event. When you receive a link message (for example, `content.type = "link"`), render it appropriately in your channel UI. For example, as a clickable link or a button, depending on the channel's capabilities.

#### Best practices

* Treat CSAT surveys as a special message type. Just display them, don't respond to them.
* Render the survey link in a way that fits your channel (like a clickable button or message).
* If you don't want Ada to send CSAT surveys, you can turn them off or customize them in your AI Agent settings.

#### What to include in the request

To end a conversation, all you need is the conversation ID of the active session. No request body is required: simply make a POST call to the endpoint that includes the `conversation_id` in the URL. This tells Ada that the conversation is complete and prevents any further messages from being added to it.

#### Sample request

This example shows the minimal HTTP request required to end an active conversation in Ada. Replace `<handle>`, `<conversation_id>`, and `<your-api-key>` with your actual values.

**`http`**

```http title="http"
  POST https://<handle>.ada.support/api/v2/conversations/<conversation_id>/end
  Authorization: Bearer <your-api-key>
```

#### Code example

This example uses the standard Python `requests` library to end a conversation synchronously. It's ideal for simple scripts or applications that don’t rely on asynchronous I/O.

**`python`**

```python title="python"
  import requests

  response = requests.post(
      f"{ADA_BASE_URL}/api/v2/conversations/{conversation_id}/end",
      headers={"Authorization": f"Bearer {ADA_API_KEY}"},
  )
  response.raise_for_status()
```

#### What happens next

Once a conversation is ended:

* Ada stops processing new messages for that session.
* If configured, a CSAT survey link or closing message is sent as a `v1.conversation.message` webhook event.

#### Important notes

* A conversation cannot be reactivated after it’s ended.
* The conversation ID remains valid for querying past messages or logs.
* Always ensure your front end reflects the closed state: disable input fields or prompt users to start a new conversation.

## Making your integration production-ready

You've already seen the note about [rate limits and retries](#rate-limits-and-retries) earlier in this guide. In production, make sure your retry logic is fully tested, especially for `HTTP 429`-type responses.

Even with well-formed requests, things can still go wrong. Network issues or invalid payloads can cause occasional hiccups. Here's how to make your integration resilient when those things happen.

#### Error handling

The Conversations API uses standard HTTP conventions for reporting errors. Here are a few best practices for production:

* **Add retries with backoff**: Retry failed requests after a short delay, increasing the delay each time.
* **Handle rate limits**: When you receive `429 Too Many Requests`, check the `Retry-After` header and wait before retrying.
* **Validate before sending**: Double-check request fields and types before making an API call.
* **Log and monitor errors**: Capture response codes and request details to help diagnose issues later.
* **Be user-friendly**: If something goes wrong, surface a helpful message instead of letting the app fail silently.

#### What happens next

At this point, your integration should be ready for production use — it can create conversations, send and receive messages, and handle webhooks reliably. From here, you can:

* Experiment with additional automation, logging, or analytics for your custom channel.
* Explore **Channels**, **Conversations**, and **Webhooks** in the sidebar for complete endpoint details.