> For the complete documentation index, see [llms.txt](https://docs.maiagent.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.maiagent.ai/api/en/preparation/message-webhook.md).

# Message Webhook

Whenever an Inbox receives a customer message or sends a reply, MaiAgent can push that message to a URL you specify. This guide covers Incoming and Outgoing webhooks, admin console and API configurati

After you configure message webhooks for an Inbox, MaiAgent pushes each message to your system without polling. Common uses include displaying new message notifications in your own system, syncing customer messages to a CRM, and logging AI replies in a data warehouse.

An Inbox can have multiple message webhooks. Each has its own type, URL, and format, and delivers messages independently.

{% hint style="info" %}
To receive notifications only for **handoffs to human agents**, use [Handoff Webhook Events](/api/en/preparation/handoff-webhook.md). These are sent only when the handoff workflow changes state, rather than for every message. The two settings are independent; you can enable both and handle them with the same receiver.
{% endhint %}

## Types: Incoming and Outgoing <a href="#directions" id="directions"></a>

| Type       | When it is sent                                                                                                                        | Payload `type` |
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------- | -------------- |
| `Incoming` | Whenever the Inbox receives a customer (contact) message                                                                               | `incoming`     |
| `Outgoing` | Whenever a reply is sent to the customer's conversation platform, including automatic AI replies and replies from human support agents | `outgoing`     |

* The admin console describes `Incoming` as “Receive external requests,” but it actually **pushes each customer message to the URL you configure**.
* `Receive` in the same menu works in the opposite direction: an external platform sends messages into MaiAgent. This is outside the scope of this guide. Messages received through `Receive` do not trigger `Incoming` or `Outgoing` webhooks for the same Inbox, preventing messages from looping between the two systems.
* To receive both customer messages and replies, configure one `Incoming` webhook and one `Outgoing` webhook. Both can use the same URL; distinguish them by the payload's `type`.

## Configuration <a href="#setup" id="setup"></a>

### Configure in the admin console <a href="#setup-admin" id="setup-admin"></a>

1. Admin console → <mark style="color:blue;">Customer Conversations</mark> → <mark style="color:blue;">Inboxes</mark> → Select an Inbox → <mark style="color:blue;">Webhook Settings</mark> → <mark style="color:blue;">Add Webhook</mark>
2. Enter a <mark style="color:blue;">Name</mark>, set <mark style="color:blue;">Type</mark> to <mark style="color:blue;">Incoming</mark> or <mark style="color:blue;">Outgoing</mark>, enter the <mark style="color:blue;">Webhook URL</mark>, and select an <mark style="color:blue;">HTTP Method</mark> (default `POST`).
3. (Recommended) Add an authentication header under <mark style="color:blue;">Headers</mark>, such as `Authorization: Bearer <YOUR_RANDOM_STRING>`. The receiver can use it to confirm that requests come from MaiAgent (see [Verify the source](#verify-source)).
4. Set <mark style="color:blue;">Payload Format</mark> to <mark style="color:blue;">MaiAgent Standard</mark> (see [Payload](#payload)). Choose <mark style="color:blue;">Custom Template</mark> if you need custom fields.
5. Make sure <mark style="color:blue;">Enabled</mark> is on, then save. Turning it off pauses delivery and retains the configuration.

The WhatsApp Inbox settings page currently has no <mark style="color:blue;">Webhook Settings</mark> tab. Use the API configuration below instead.

### Configure through the API <a href="#setup-api" id="setup-api"></a>

Message webhooks belong to an Inbox:

| Action       | Method and path                                                                    |
| ------------ | ---------------------------------------------------------------------------------- |
| List         | `GET /api/v1/inboxes/{inbox_id}/webhooks/`                                         |
| Create       | `POST /api/v1/inboxes/{inbox_id}/webhooks/`                                        |
| Retrieve one | `GET /api/v1/inboxes/{inbox_id}/webhooks/{id}/`                                    |
| Update       | `PATCH /api/v1/inboxes/{inbox_id}/webhooks/{id}/` (send only the fields to change) |
| Delete       | `DELETE /api/v1/inboxes/{inbox_id}/webhooks/{id}/`                                 |

The API key owner must have permission to manage the Inbox. See [API Authentication → API key permissions](/api/en/preparation/authentication.md#api-key-permissions).

| Field            | Required              | Description                                                                                                                                                                          |
| ---------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `name`           | Yes                   | A name for easy identification                                                                                                                                                       |
| `direction`      | Yes                   | `incoming` or `outgoing`                                                                                                                                                             |
| `url`            | Yes                   | The URL that receives deliveries; it must be accessible from the internet (`https://` recommended). Delivery to URLs pointing to internal networks or local addresses is blocked     |
| `method`         | No                    | `POST` (default), `PUT`, or `PATCH`                                                                                                                                                  |
| `headers`        | No                    | Custom headers included with every delivery, as a JSON object, such as `{"Authorization": "Bearer ..."}`                                                                             |
| `templatePreset` | No                    | `maiagent_standard` (default), `custom`, or `genesys_cloud` for Genesys Cloud integration                                                                                            |
| `customTemplate` | Required for `custom` | A Jinja2 template whose rendered output must be JSON. A test render runs when saving; invalid formatting returns 400                                                                 |
| `secretToken`    | No                    | The signing secret. When set, each delivery includes an HMAC-SHA256 signature and timestamp headers (see [Verify the source](#verify-source)). Write-only; never returned in queries |
| `isEnabled`      | No                    | Whether the webhook is enabled; defaults to `true`                                                                                                                                   |

```bash
curl -X POST "https://api.maiagent.ai/api/v1/inboxes/YOUR_INBOX_ID/webhooks/" \
  -H "Authorization: Api-Key YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "New message notification",
    "direction": "incoming",
    "url": "https://example.com/webhooks/maiagent-message",
    "secretToken": "YOUR_RANDOM_SECRET"
  }'
```

A successful request returns `201` and the configuration (excluding `secretToken`). To also receive replies, create another webhook with `"direction": "outgoing"`.

## Payload <a href="#payload" id="payload"></a>

Each message is delivered as a single HTTP request:

* Headers: `Content-Type: application/json`, your custom headers, `X-MaiAgent-Delivery-Attempt` (the attempt number, starting at `1`), and, when `secretToken` is set, `X-MaiAgent-Timestamp` and `X-MaiAgent-Signature-256`. Custom headers with the same names as these three headers (case-insensitive) are removed before sending.
* Body: JSON generated according to the <mark style="color:blue;">Payload Format</mark>.

### MaiAgent Standard format <a href="#standard-format" id="standard-format"></a>

All fields use snake\_case, unlike the camelCase used in API responses. The following is an `Outgoing` (AI reply) payload:

```json
{
  "id": "a0c49ae2-013c-4310-b00f-b4554bc9cd83",
  "type": "outgoing",
  "content": "Hello, we have received your order inquiry. Please provide your order number.",
  "created_at": "2026-10-03T04:59:01.512340+00:00",
  "conversation": {
    "id": "e6b27044-4990-4129-ad8e-ac7649984e7d",
    "title": "Change order address",
    "type": "individual",
    "status": "open",
    "progress_status": "ai_processing",
    "auto_reply_enabled": true,
    "assignee": null
  },
  "inbox": { "id": "6571672b-2fae-4200-a4d0-cf12e86216c3", "name": "Website support", "channel_type": "web" },
  "contact": { "id": "350691e0-66a5-48ea-855c-d8bb90a7e24a", "name": "Wang Xiaoming", "email": null, "phone_number": null },
  "sender": { "id": "8a214fc3-75ad-43ba-93f6-567e9df97b76", "name": "Support assistant", "type": "chatbot" },
  "attachments": [],
  "metadata": {}
}
```

| Field          | Description                                                                                                                                                                                                          |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`           | Message ID; use it for deduplication or look it up through the [Message API](/api/en/api-reference/dui-hua-he-xun-xi.md)                                                                                             |
| `type`         | `incoming` (customer message) or `outgoing` (reply); use this field to determine the direction                                                                                                                       |
| `content`      | Message text                                                                                                                                                                                                         |
| `created_at`   | Message creation time (ISO 8601, UTC)                                                                                                                                                                                |
| `conversation` | The conversation this message belongs to. A `progress_status` of `human_serving` means a human support agent is handling it. `assignee` is the assigned support agent as `{ "id", "name" }`, or `null` if unassigned |
| `inbox`        | Inbox as `{ "id", "name", "channel_type" }`; examples of `channel_type` include `web`, `line`, and `messenger`                                                                                                       |
| `contact`      | The customer (contact); `email` and `phone_number` are `null` when unavailable                                                                                                                                       |
| `sender`       | The speaker. `type` is `contact` (customer), `chatbot` (Agent), or `user` (organization member, such as a human support agent; messages sent on a customer's behalf using an API key also have type `user`)          |
| `attachments`  | An array of attachments, each containing `id`, `type` (`image` / `video` / `audio` / `sticker` / `other`), `filename`, and `source_url`                                                                              |
| `metadata`     | Additional message information; its contents vary by Inbox                                                                                                                                                           |

### Custom templates <a href="#custom-template" id="custom-template"></a>

<mark style="color:blue;">Custom Template</mark> (`templatePreset: "custom"` in the API) uses Jinja2 syntax to build the JSON you need. Available variables are `message` (`id`, `type`, `content`, `created_at`, `metadata`), `conversation`, `inbox`, `contact`, `sender`, `sender_type` (`contact` / `chatbot` / `user`), `attachments`, and `now`. Output strings with the `tojson` filter to prevent quotes or line breaks from breaking the JSON:

```
{
  "text": {{ message.content | tojson }},
  "direction": "{{ message.type }}",
  "conversation_id": "{{ conversation.id }}",
  "customer": {{ (contact.name if contact else none) | tojson }}
}
```

If the template fails to render at delivery time, that message is not sent.

## Verify the source <a href="#verify-source" id="verify-source"></a>

When you set `secretToken` (the API field, or <mark style="color:blue;">Secret Token</mark> in the admin console's webhook form; this field is hidden in the console when the payload format is Genesys Cloud), each delivery includes these headers:

| Header                     | Content                                                                                              |
| -------------------------- | ---------------------------------------------------------------------------------------------------- |
| `X-MaiAgent-Timestamp`     | Unix time at the moment of sending, in seconds                                                       |
| `X-MaiAgent-Signature-256` | `sha256=` followed by the hexadecimal value of `HMAC-SHA256(secretToken, "{timestamp}." + raw body)` |

Verification steps for the receiver:

1. Calculate the signature using the **original raw body bytes** you received. Do not parse the JSON and serialize it again first; the bytes will differ and the signature will not match.
2. Compare it against `X-MaiAgent-Signature-256` using a constant-time comparison, such as Python's `hmac.compare_digest`.
3. Reject requests when `X-MaiAgent-Timestamp` differs from the receiver's current time by more than the allowed tolerance (for example, 5 minutes) to prevent replay attacks. The timestamp and signature are recalculated at send time for every attempt, including retries.

For details, see [Verify signatures and timestamps](https://docs.maiagent.ai/tech/serve/webhook#webhook-signature) in the technical manual.

* If you do not use signatures, you can set an authentication header in `headers`, such as `Authorization: Bearer <RANDOM_STRING>`. Values in `headers` are returned unchanged when querying webhook settings and are visible to any organization member who can view that Inbox's settings. `secretToken` is write-only and is never returned.
* When `secretToken` is set, the legacy `X-Webhook-Secret` header (containing the raw `secretToken` value) is also sent for compatibility with existing receivers. New integrations should verify the signature instead.
* Use a sufficiently long random secret for authentication and receive requests over HTTPS.

## Responses and retries <a href="#response-and-retry" id="response-and-retry"></a>

* Delivery runs in the background after the message is saved, usually arriving within a few seconds. Messages are sent independently, and **arrival order is not guaranteed**. Sort by `created_at` and deduplicate by `id`.
* The receiver must return a 2xx response within **30 seconds**. Timeouts, connection failures, and non-2xx responses count as failures.
* **Temporary failures are retried automatically**: timeouts, connection failures, `5xx`, and `429` are retried once each after approximately 1, 5, and 15 minutes, for a maximum of 3 retries (4 attempts including the initial delivery). Other `4xx` responses, destinations blocked by security protections, and custom template rendering failures are not retried. The retry body is identical to the initial body; `X-MaiAgent-Delivery-Attempt` increments to `2`, `3`, and `4`. Intervals are fixed; the response's `Retry-After` is not used. Retries stop if the webhook is disabled or deleted, or the message is deleted, before the next attempt. See [Automatic retries](https://docs.maiagent.ai/tech/serve/webhook#retry) in the technical manual for details.
* With retries, a message can be delivered “at least once”: if the receiver has processed it but its response times out or returns `5xx`, the same message will arrive again. Deduplicate by message `id`. Save the content before responding with 2xx and move time-consuming processing to the background. Return `4xx` only when the message can never be processed; return `5xx` or `429` for temporary problems, such as a downstream service outage.
* Delivery stops after all 3 retries fail; message webhooks **do not guarantee delivery**. The polling approach in [Detect new messages](/api/en/preparation/detect-new-messages.md#polling) can check for missed **customer messages** (Incoming) at a low frequency. AI and support replies (Outgoing) often appear after the customer message, and the polling example may not read them again, so it cannot recover replies whose webhook delivery failed. To identify failed deliveries, query the delivery records described below. A `statusCode` that is not 2xx or is empty (timeout or connection failure) indicates a failed delivery. `requestBody` contains the payload sent at the time (the MaiAgent Standard format includes the message `id` and `conversation.id`); then read the first page of that conversation's message list to recover the missing messages.
* Each attempt, including retries, creates its own delivery record with the URL, body, and response status code. Query them with `GET /api/v1/chatbots/{chatbot_id}/webhook-records/` (requires edit permission for that Agent; `chatbot_id` is the Agent linked to the Inbox). This API also returns camelCase fields, while the actual delivered payload uses snake\_case.
* Failed webhook deliveries do not affect message sending, receiving, or AI replies.

## Minimal receiver example <a href="#receiver-example" id="receiver-example"></a>

The following Python (Flask) receiver can be started directly. It verifies signatures and timestamps, deduplicates by message `id` (the same message may arrive again on retries), routes by `type`, and returns 2xx before processing. Set `MESSAGE_WEBHOOK_SECRET` to your configured `secretToken`. If you use a custom authentication header instead, read the corresponding header.

{% code title="receiver.py" %}

```python
import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["MESSAGE_WEBHOOK_SECRET"]
TOLERANCE_SECONDS = 300
processed_ids: set[str] = set()  # In production, use a database or Redis and set a retention period


def is_valid_signature(timestamp: str, signature: str, body: bytes) -> bool:
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
        return False
    expected = "sha256=" + hmac.new(SECRET.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature, expected)


@app.post("/webhooks/maiagent-message")
def message_webhook():
    if not is_valid_signature(
        request.headers.get("X-MaiAgent-Timestamp", ""),
        request.headers.get("X-MaiAgent-Signature-256", ""),
        request.get_data(),
    ):
        abort(401)

    message = request.get_json(force=True)
    if message["id"] in processed_ids:
        return "", 204
    processed_ids.add(message["id"])

    conversation_id = message["conversation"]["id"]
    if message["type"] == "incoming":
        notify_new_message(conversation_id, message["content"])  # For example, display a new message notification
    elif message["type"] == "outgoing":
        archive_reply(conversation_id, message["content"])

    return "", 204  # Return 2xx within 30 seconds; timeouts or 5xx trigger retries. Run slow tasks in the background


def notify_new_message(conversation_id, content):
    ...


def archive_reply(conversation_id, content):
    ...
```

{% endcode %}

```bash
MESSAGE_WEBHOOK_SECRET=YOUR_SECRET flask --app receiver run --port 8080
```

Use the following commands to simulate a delivery with a valid signature for local testing:

```bash
BODY='{"id":"acadbd9c-0000-0000-0000-000000000001","type":"incoming","content":"I want to change my order address","created_at":"2026-10-03T04:58:55.262828+00:00","conversation":{"id":"e6b27044-0000-0000-0000-000000000002","title":"Change order address","type":"individual","status":"open","progress_status":"ai_processing","auto_reply_enabled":true,"assignee":null},"inbox":{"id":"6571672b-0000-0000-0000-000000000003","name":"Website support","channel_type":"web"},"contact":{"id":"350691e0-0000-0000-0000-000000000004","name":"Wang Xiaoming","email":null,"phone_number":null},"sender":{"id":"350691e0-0000-0000-0000-000000000004","name":"Wang Xiaoming","type":"contact"},"attachments":[],"metadata":{}}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "YOUR_SECRET" | sed 's/^.* //')
curl -X POST http://localhost:8080/webhooks/maiagent-message \
  -H "Content-Type: application/json" \
  -H "X-MaiAgent-Timestamp: $TS" \
  -H "X-MaiAgent-Signature-256: sha256=$SIG" \
  --data-raw "$BODY"
```


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.maiagent.ai/api/en/preparation/message-webhook.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
