> 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/tech/en/serve/webhook.md).

# Webhook

Conversation platform Webhooks: push visitor messages and AI or human replies to your system in real time, with support for the MaiAgent standard format, Genesys Cloud, and custom Jinja2 templates

Webhooks are configured on a **conversation platform (Inbox)**. Whenever a new message is created in a conversation on that platform, MaiAgent pushes the message to your specified endpoint via an HTTP request. A conversation platform can have multiple Webhooks, each with its own message direction and format.

Configuration path: <mark style="color:blue;">Customer Support Conversations</mark> → <mark style="color:blue;">Conversation Platforms</mark> → Edit platform → <mark style="color:blue;">Webhook Settings</mark> → <mark style="color:blue;">Add Webhook</mark>. For API platforms, <mark style="color:blue;">Webhook Settings</mark> is in the “Integrations and Login” group on the left. For messaging platforms such as LINE, it is in the tab bar at the top. Web and internal platforms do not have this tab. For admin interface instructions, see [Webhook](https://docs.maiagent.ai/build/conversation-settings/webhook) in the user manual.

{% hint style="info" %}
When you send a message through the [Create conversations and messages](/tech/en/api-integration/conversations-and-messages.md) API, the response only confirms that the message was created. **The Agent's reply is generated asynchronously and pushed to you through an Outgoing Webhook**. This is the standard way to receive replies in an API integration.
{% endhint %}

## How it works <a href="#how-it-works" id="how-it-works"></a>

```mermaid
sequenceDiagram
    participant C as Your system
    participant M as MaiAgent
    participant AI as Agent

    C->>M: POST /api/v1/messages/ (visitor message)
    M-->>C: 201 Created (no reply included)
    M-)C: Incoming Webhook (visitor message)
    M->>AI: Generate reply
    AI-->>M: Reply message created
    M-)C: Outgoing Webhook (AI reply)
```

When a human agent replies in the admin console, that reply is also an Outgoing message and is pushed to the Outgoing Webhook as well. The payload's `sender.type` tells you whether it came from the AI or a human.

## Webhook types <a href="#directions" id="directions"></a>

| Type         | Trigger                                                                             | Typical use                                                                                                             |
| ------------ | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| **Outgoing** | When a reply from the Agent or a human agent is created                             | Get AI replies; forward replies to your own front end or messaging app                                                  |
| **Incoming** | When a visitor message is created (all channels, including API, Web Chat, and LINE) | Sync conversation records to a customer service system, data warehouse, or audit log                                    |
| **Receive**  | Called by an external platform to MaiAgent                                          | Receive messages sent back from an external customer service platform (currently supports Genesys Cloud Open Messaging) |

This page covers Outgoing and Incoming. For Receive and the Genesys Cloud format, see [Genesys Cloud Integration](/tech/en/serve/customer-service-system-integration/genesys-cloud-integration.md).

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

| Field                                                                   | Description                                                                                                                                                                                                                                                                                                                                                                                                |
| ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| <mark style="color:blue;">Name</mark>                                   | A custom identifying name                                                                                                                                                                                                                                                                                                                                                                                  |
| <mark style="color:blue;">Type</mark>                                   | Outgoing / Incoming / Receive                                                                                                                                                                                                                                                                                                                                                                              |
| <mark style="color:blue;">Webhook URL</mark>                            | The URL that receives the pushes                                                                                                                                                                                                                                                                                                                                                                           |
| <mark style="color:blue;">HTTP Method</mark>                            | `POST` (default) / `PUT` / `PATCH`                                                                                                                                                                                                                                                                                                                                                                         |
| <mark style="color:blue;">Headers</mark>                                | Custom headers added to every request (for example, your system's API key)                                                                                                                                                                                                                                                                                                                                 |
| <mark style="color:blue;">Payload format</mark>                         | **MaiAgent standard format** / **Genesys Cloud** / **Custom template** (Jinja2)                                                                                                                                                                                                                                                                                                                            |
| <mark style="color:blue;">Enabled</mark>                                | When off, nothing is pushed, but the settings are kept                                                                                                                                                                                                                                                                                                                                                     |
| <mark style="color:blue;">Secret Token</mark> (API field `secretToken`) | Signing secret. Once set, every push carries an HMAC-SHA256 signature and a timestamp header; see [Verifying signatures and timestamps](#webhook-signature). Write-only and never returned in queries; leaving it blank when editing in the admin console keeps the existing value. This field is not shown in the admin console when the <mark style="color:blue;">Payload format</mark> is Genesys Cloud |

{% hint style="info" %}
If <mark style="color:blue;">Headers</mark> contains a custom header with the same name (case-insensitive) as `X-MaiAgent-Signature-256`, `X-MaiAgent-Timestamp`, or `X-MaiAgent-Delivery-Attempt`, it is removed before sending, and the value generated by MaiAgent is always used.
{% endhint %}

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

`Content-Type: application/json`, with the following content (Outgoing and Incoming share the same structure, distinguished by `type`):

```json
{
  "id": "2491074a-6d23-4289-af45-caa12531420e",
  "type": "outgoing",
  "content": "Hello, how can I help you?",
  "created_at": "2026-09-09T10:15:30.123456+00:00",
  "conversation": {
    "id": "8e44604a-8278-4c96-96b5-3e5a0548d738",
    "title": "Asking about the return process",
    "type": "individual",
    "status": "open",
    "progress_status": "ai_processing",
    "auto_reply_enabled": true,
    "assignee": null
  },
  "inbox": {
    "id": "1f9c1c1e-4d1a-4b1e-9d0e-7b2f4a5c6d7e",
    "name": "Website Support",
    "channel_type": "web"
  },
  "contact": {
    "id": "c0ffee00-1111-2222-3333-444455556666",
    "name": "Xiao-Ming Wang",
    "email": null,
    "phone_number": null
  },
  "sender": {
    "id": "5c1d3e2a-0000-4b1e-9d0e-7b2f4a5c6d7e",
    "name": "AI Support Agent",
    "type": "chatbot"
  },
  "attachments": [
    {
      "id": "a1b2c3d4-0000-4b1e-9d0e-7b2f4a5c6d7e",
      "type": "image",
      "filename": "photo.jpg",
      "source_url": "https://example.com/photo.jpg"
    }
  ],
  "metadata": {}
}
```

### Field descriptions <a href="#standard-fields" id="standard-fields"></a>

| Field                             | Type           | Description                                                                                                                               |
| --------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                              | string         | Message ID (UUID)                                                                                                                         |
| `type`                            | string         | `incoming` (visitor message) / `outgoing` (AI or human reply)                                                                             |
| `content`                         | string         | Message text                                                                                                                              |
| `created_at`                      | string         | ISO 8601 time                                                                                                                             |
| `conversation.id`                 | string         | Conversation ID                                                                                                                           |
| `conversation.title`              | string         | Conversation title                                                                                                                        |
| `conversation.type`               | string         | `individual` / `group`                                                                                                                    |
| `conversation.status`             | string         | `open` / `queued` (waiting for a human) / `resolved`                                                                                      |
| `conversation.progress_status`    | string         | Who is currently handling it: `ai_processing` / `waiting_for_human` / `human_serving` / `resolved` / `open`                               |
| `conversation.auto_reply_enabled` | boolean        | `false` means the AI has stopped auto-replying (handed off to a human)                                                                    |
| `conversation.assignee`           | object \| null | The support member who took over, `{ id, name }`; `null` if unassigned                                                                    |
| `inbox.id` / `inbox.name`         | string         | Conversation platform ID and name                                                                                                         |
| `inbox.channel_type`              | string         | `web` / `line` / `messenger` / `instagram` / `telegram` / `teams` / `email` / `whatsapp` / `slack` / `team_plus` / `line_works` / `viber` |
| `contact`                         | object \| null | The visitor (contact) `{ id, name, email, phone_number }`                                                                                 |
| `sender.type`                     | string         | `chatbot` (Agent) / `user` (human agent) / `contact` (visitor)                                                                            |
| `attachments[]`                   | array          | Attachments `{ id, type, filename, source_url }`; `type` is `image` / `video` / `audio` / `sticker` / `other`                             |
| `metadata`                        | object         | Additional message data                                                                                                                   |

{% hint style="warning" %}
`attachments[].source_url` is the attachment's **source URL** (the original link supplied by an external platform). For files stored by MaiAgent (for example, API uploads or AI-generated files), this field may be empty. If you need a downloadable URL, use a custom template and take `attachment.file.url` (see the example below), or call the message API with the message ID to get the attachment.
{% endhint %}

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

When your system requires a specific JSON structure, select <mark style="color:blue;">Custom template</mark> and write the payload in Jinja2 syntax. The rendered result must be valid JSON; it is validated once with test data when you save.

### Available variables <a href="#template-variables" id="template-variables"></a>

| Variable       | Contents                                                                                                                                               |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `message`      | `id`, `type`, `content`, `created_at` (datetime), `metadata`                                                                                           |
| `conversation` | `id`, `title`, `type`, `status`, `progress_status`, `auto_reply_enabled`, `assignee` (may be `None`; when set, `assignee.id` and `assignee.user.name`) |
| `inbox`        | `id`, `name`, `channel_type`                                                                                                                           |
| `contact`      | `id`, `name`, `email`, `phone_number`, `source_id` (the `sourceId` you passed in through the identity sync API); may be `None`                         |
| `sender`       | `id`, `name`; `sender_type` is `chatbot` / `user` / `contact`                                                                                          |
| `attachments`  | List of attachments, each with `id`, `type`, `filename`, `source_url`, and `file` (`file.url` is the downloadable URL)                                 |
| `now`          | The time at render (datetime)                                                                                                                          |

Always output strings through the `| tojson` filter so that quotes and line breaks are escaped correctly.

### Example: return your system's user ID and downloadable attachment URLs <a href="#template-example" id="template-example"></a>

```jinja
{
  "external_user_id": {{ (contact.source_id if contact else "") | tojson }},
  "conversation_id": "{{ conversation.id }}",
  "direction": "{{ message.type }}",
  "from_human_agent": {{ (sender_type == "user") | tojson }},
  "text": {{ message.content | tojson }},
  "attachments": [{% for a in attachments %}{
    "type": "{{ a.type }}",
    "url": {{ (a.file.url if a.file else a.source_url) | tojson }}
  }{% if not loop.last %},{% endif %}{% endfor %}],
  "sent_at": "{{ message.created_at.isoformat() }}"
}
```

## Verifying signatures and timestamps <a href="#webhook-signature" id="webhook-signature"></a>

When a Secret Token is set on a Webhook, every push carries the following headers. The receiver can use them to confirm that the request came from MaiAgent, that the content has not been tampered with, and to reject stale replayed requests:

| Header                        | Contents                                                                                                                                                                                      |
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `X-MaiAgent-Timestamp`        | The Unix time (in seconds) at the moment of this send                                                                                                                                         |
| `X-MaiAgent-Signature-256`    | `sha256=<hex>`: the HMAC-SHA256 computed with the Secret Token as the key over "`timestamp` + `.` + raw request body", in lowercase hexadecimal                                               |
| `X-MaiAgent-Delivery-Attempt` | Which attempt this is; the first is `1` (always included, whether or not a Secret Token is set)                                                                                               |
| `X-Webhook-Secret`            | The Secret Token in plain text. It is still sent for now for compatibility with existing receivers that only compare this value; it is no longer recommended, so verify the signature instead |

### Verification steps <a href="#verify-steps" id="verify-steps"></a>

1. Read the **raw bytes of the request body**; do not parse the JSON and then re-serialize it. MaiAgent sends compact (no whitespace), UTF-8 encoded JSON without escaping Chinese characters. The bytes after re-serialization usually differ, and the signature will not match.
2. Take `X-MaiAgent-Timestamp` and compare it with the receiver's current time; reject the request if it exceeds the tolerance you set (for example, 5 minutes). Every attempt (including resends) recomputes the timestamp and signature at the moment of sending, so a resend is not rejected for having a stale timestamp.
3. Compute `HMAC-SHA256(secret, f"{timestamp}.".encode() + raw_body)` with the Secret Token, convert it to hexadecimal, and prefix it with `sha256=`.
4. Compare it with `X-MaiAgent-Signature-256` using a constant-time comparison (for example, Python's `hmac.compare_digest`); if they do not match, respond with `401`.

```python
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300  # Custom timestamp tolerance


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

{% hint style="warning" %}
The signature header of the human handoff Webhook is also named `X-MaiAgent-Signature-256`, but it signs only the request body and does not include a timestamp. If one receiver handles both, verify them separately by path; see [Third-party customer service system integration (including human handoff)](/tech/en/serve/customer-service-system-integration.md).
{% endhint %}

## Delivery behavior <a href="#delivery" id="delivery"></a>

* Each message is pushed once for each enabled Webhook; the request times out after **30 seconds**.
* A **2xx** response is treated as success.
* Endpoint URLs go through SSRF protection: destinations on private network ranges or internal addresses are rejected.
* Every attempt (including resends) keeps its own record of the request content, response, and status code, which you can view for troubleshooting in the <mark style="color:blue;">Webhook</mark> tab of the Agent page.

### Automatic resends <a href="#retry" id="retry"></a>

| Result of each attempt                                                                                                                                         | Handling                                                                                                                                                 |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Timeout, connection failure, `5xx`, `429`                                                                                                                      | Treated as a temporary failure; resent once each after about **1, 5, and 15 minutes**, for **up to 3 resends** (4 attempts in total including the first) |
| Other `4xx` (for example, `400`, `401`, `404`)                                                                                                                 | Recorded as failed; not resent                                                                                                                           |
| Destination blocked by SSRF protection (a domain name resolution failure also counts, including a temporary DNS failure), or custom template rendering failure | Recorded as failed; not resent                                                                                                                           |

* The request body of a resend is **byte-for-byte identical** to the first one; `X-MaiAgent-Delivery-Attempt` is `2`, `3`, and `4` in order, and the timestamp and signature are recomputed at the moment of sending.
* The URL, custom headers, and Secret Token are taken from the Webhook settings at the time of the resend; if the Webhook has been disabled or deleted, or the message has been deleted, before a resend, it is not resent.
* After all 3 resends fail, MaiAgent gives up and does not send again. To backfill data, call `GET /api/v1/messages/?conversation={conversationId}` to query the messages of that conversation (the `conversation` parameter is required; see [Conversation and message replies (streaming/synchronous)](/tech/en/api-integration/dui-hua-yu-xun-xi-hui-fu-chuan-liu-tong-bu.md)).
* The resend intervals are fixed; the `Retry-After` header in the response is not read.
* When the payload format is Genesys Cloud, a `401` first refreshes the access token and immediately resends once, without using up the 3 resends above; if that resend hits a temporary failure, it is resent on the schedule in the table above, and if it is still `401`, it stops.

### Idempotency recommendations <a href="#idempotency" id="idempotency"></a>

With resends, the delivery semantics are "at least once": if your endpoint has actually processed a request but the response timed out or returned `5xx`, you will receive the same message again. We recommend that you:

* Deduplicate by the message `id` in the payload, and return `2xx` directly for any `id` you have already processed. When using a custom template, keep `message.id` in the template; otherwise you cannot deduplicate.
* Deduplicate by message `id`, not by `X-MaiAgent-Timestamp` or the signature: both change on every attempt.
* Persist the received content (write it to a database or queue) before returning `2xx`, and move time-consuming processing to run asynchronously, so you do not exceed the 30-second timeout and trigger unnecessary resends.
* Return `4xx` (which is not resent) only when you are sure the request can never be processed; if it temporarily cannot be processed (for example, a downstream service is down), return `5xx` or `429` so that MaiAgent resends it later.

## Managing Webhooks via API <a href="#manage-by-api" id="manage-by-api"></a>

In addition to the admin interface, you can also create and maintain Webhooks through the API (the fields correspond to the admin console settings). For authentication, see [Quick start](/tech/en/api-integration/quickstart.md).

```bash
# List / create
GET  https://api.maiagent.ai/api/v1/inboxes/{inboxId}/webhooks/
POST https://api.maiagent.ai/api/v1/inboxes/{inboxId}/webhooks/

# Update / delete
PATCH  https://api.maiagent.ai/api/v1/inboxes/{inboxId}/webhooks/{webhookId}/
DELETE https://api.maiagent.ai/api/v1/inboxes/{inboxId}/webhooks/{webhookId}/
```

```json
{
  "name": "Sync replies to customer service system",
  "direction": "outgoing",
  "isEnabled": true,
  "url": "https://your-system.example.com/maiagent/reply",
  "method": "POST",
  "headers": { "X-Api-Key": "your-key" },
  "templatePreset": "maiagent_standard",
  "customTemplate": null,
  "secretToken": "shared-secret"
}
```

You can get `inboxId` from the `inbox.id` in the response of [Create conversation](/tech/en/api-integration/conversations-and-messages.md), or look it up on the conversation platform settings page in the admin console.

## Differences from the "human handoff Webhook" <a href="#vs-handoff-webhook" id="vs-handoff-webhook"></a>

The Webhook on this page pushes by **message**. If your system needs to know **when a conversation is handed off to a human and when it returns to the AI** (for example, when your customer service system takes over the service), use the separate human handoff Webhook, which pushes by conversation lifecycle; see [Third-party customer service system integration (including human handoff)](/tech/en/serve/customer-service-system-integration.md).


---

# 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/tech/en/serve/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.
