> 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/handoff-webhook.md).

# Human Handoff Webhook Events

Webhook delivery sends the human handoff lifecycle (AI requests handoff, agent assignment, return to AI, and queue timeout) to your system endpoint. This guide covers event types and formats, signatur

At each stage of the human handoff workflow—AI determines that a human is needed, assignment to a support agent, return to AI, or queue timeout—MaiAgent sends an event via HTTPS POST to your configured endpoint. This lets dispatching, alerts, CRM, or your own customer service system take over automatically, without polling. Event names describe handoff state transitions and form a fixed public contract. Each event has a corresponding `event_type` in the conversation event log API (`GET /api/v1/conversations/{id}/events/`; see the table below), which you can use for reconciliation or to retrieve the same information through polling.

## How to Enable

Customer administrators or integration staff can configure this themselves in the admin console, without engineering assistance:

1. In the admin console, go to “Customer Service Conversations” → “Conversation Platforms” → Edit the conversation platform to integrate → “Human Handoff Settings” → “Notifications” tab
2. Enable “Webhook Notifications (System Integration)” and enter the receiver URL (only URLs starting with `https://` are accepted)
3. (Optional but recommended) Enter a signing secret to enable source verification (see “Signature Verification” below)

Webhook, Notification Center, and Email each have independent switches and do not affect one another. They are disabled by default and enabled per inbox. This channel is separate from the inbox’s existing “Message Webhook” for sending and receiving messages.

## Event Types <a href="#event-types" id="event-types"></a>

| event                    | Event log API `event_type` | When sent                                                                                                                                                      | Notes                                                                                                                                                                                                                                                                                                                                                                                                                |
| ------------------------ | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `handoff.requested`      | `transfer_triggered`       | AI determines a human is needed: the conversation enters the queue and AI stops replying automatically                                                         | The first event of each handoff workflow; `assignee` is `null`. If human support agents use your own system, this signals them to take over                                                                                                                                                                                                                                                                          |
| `handoff.assigned`       | `human_assigned`           | The conversation is assigned to a support agent in the MaiAgent admin console (including manual assignment, self-claiming, transfer, and automatic assignment) | `assignee` identifies the assigned agent; transferring to another agent sends another event with a different event `id`                                                                                                                                                                                                                                                                                              |
| `handoff.returned_to_ai` | `returned_to_ai`           | A support agent clicks “Return to AI Agent” in the admin console or returns the conversation to AI using the `return-to-ai` API                                | `assignee` is `null` (the conversation no longer has an assigned agent)                                                                                                                                                                                                                                                                                                                                              |
| `handoff.queue_timeout`  | `queue_timeout`            | No agent takes over before the inbox’s configured “Waiting Timeout” expires, and MaiAgent automatically returns the conversation to AI                         | `assignee` is `null`. If human support agents use your own system, receiving this means MaiAgent has resumed AI replies without your awareness—treat it as an exception requiring an alert. Alternatively, select “Handover” in dispatch settings and check “No Timeout” (`dispatchMode: "handover"` and `assignmentQueueTimeout: 0` in the API; in automatic dispatch mode, a value of 0 is changed to 300 seconds) |

All four event types use exactly the same payload keys, signatures, and retry behavior. The receiver should **route by the `event` field**. New event types may be added in the future; return 2xx and ignore unknown `event` values rather than treating them as errors.

Both `handoff.returned_to_ai` and `handoff.queue_timeout` mean that AI has resumed automatic replies. The former is initiated by a person (an agent or your system), while the latter happens automatically after a timeout. Neither means that the conversation is closed: no event is sent when an agent directly marks a conversation as “Resolved,” or when a conversation is requeued because the agent loses inbox permissions. For complete closure signals, query `status` through the conversation API or observe changes in `conversation.progress_status` in Message Webhooks.

## Event Format

```
POST <your configured URL>
Content-Type: application/json
```

```json
{
  "event": "handoff.assigned",
  "id": "9f0e1d2c-...",
  "occurred_at": "2026-09-09T02:30:00.123456+00:00",
  "conversation": {
    "id": "6c2b7c1e-...",
    "url": "https://admin.maiagent.ai/..."
  },
  "inbox": { "id": "0f9a3d42-..." },
  "organization": { "id": "b1d20c77-..." },
  "contact": { "id": "a4c1f6b3-..." },
  "assignee": { "id": "3e8f5a90-..." }
}
```

| Field            | Type               | Description                                                                                                                                                                                                |
| ---------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| event            | string             | Event name (see “Event Types” above)                                                                                                                                                                       |
| id               | string (uuid)      | Event identifier, identical to the `id` returned by the event log API. Retry payloads are identical; **use this to deduplicate**                                                                           |
| occurred\_at     | string (date-time) | Event occurrence time (ISO 8601). Deliveries are not guaranteed to arrive in order; **use this to sort them**                                                                                              |
| conversation.id  | string (uuid)      | Conversation identifier                                                                                                                                                                                    |
| conversation.url | string (uri)       | Link to the conversation page in the admin console                                                                                                                                                         |
| inbox.id         | string (uuid)      | Inbox identifier                                                                                                                                                                                           |
| organization.id  | string (uuid)      | Organization identifier                                                                                                                                                                                    |
| contact          | object \| null     | Customer (contact) `{ "id" }`; `null` if the conversation has no contact                                                                                                                                   |
| assignee         | object \| null     | Assigned support agent `{ "id" }` **at the time of the event**—a snapshot that does not change due to delivery delays or later transfers. Only `handoff.assigned` has a value; all other events use `null` |

Field names and nesting match the MaiAgent standard format of the inbox’s “Message Webhook” (snake\_case, with related objects nested as `{ "id" }`), so the same receiver can handle both Webhook groups. The payload contains only identifiers, the event time, and an admin console link. It **does not include conversation text or the reason for handoff**. To obtain conversation content, use the identifiers to query the conversation and message APIs.

## Signature Verification (Recommended)

After you configure a signing secret in the admin console, every delivery includes the following header:

```
X-MaiAgent-Signature-256: sha256=<hex digest>
```

The value is the hexadecimal HMAC-SHA256 digest of the **original request body bytes**, calculated with the secret. Always verify using the original bytes received; do not parse and reserialize them first (differences in field order or whitespace produce a different digest).

```python
import hashlib
import hmac


def verify_signature(raw_body: bytes, header_value: str, secret: str) -> bool:
    expected = 'sha256=' + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header_value)
```

The secret is write-only: it is not displayed after saving, and the admin console shows only “Configured.” Saving with the field empty keeps the existing secret. Enter a new value to replace it, or use “Clear Secret” to remove it. If no secret is configured, deliveries do not include the signature header.

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

The following Python (Flask) receiver can be started directly. It verifies the signature using raw bytes, routes by `event`, and returns 2xx before performing time-consuming work. Set `HANDOFF_WEBHOOK_SECRET` to the signing secret configured in the admin console.

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

```python
import hashlib
import hmac
import os

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["HANDOFF_WEBHOOK_SECRET"]


def verify_signature(raw_body: bytes, header_value: str | None) -> bool:
    if not header_value:
        return False
    expected = "sha256=" + hmac.new(SECRET.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header_value)


@app.post("/webhooks/handoff")
def handoff_webhook():
    raw = request.get_data()  # Original bytes; do not reserialize request.json
    if not verify_signature(raw, request.headers.get("X-MaiAgent-Signature-256")):
        abort(401)

    event = request.get_json(force=True)
    if already_processed(event["id"]):  # Retries send identical content; deduplicate by event ID
        return "", 204

    conversation_id = event["conversation"]["id"]
    if event["event"] == "handoff.requested":
        # AI has stopped replying; if agents use your system, create a ticket and dispatch an agent here
        open_ticket(conversation_id, event["conversation"]["url"])
    elif event["event"] == "handoff.assigned":
        # Assigned to an agent in the MaiAgent admin console; transfer sends another event with a new assignee
        assign_ticket(conversation_id, event["assignee"]["id"])
    elif event["event"] == "handoff.returned_to_ai":
        close_ticket(conversation_id)
    elif event["event"] == "handoff.queue_timeout":
        # MaiAgent has automatically resumed AI replies; alert if your agents are still handling the conversation
        close_ticket(conversation_id, reason="queue_timeout")
    # Unknown event: return 2xx and ignore it rather than treating it as an error

    return "", 204  # Return 2xx within 10 seconds; run time-consuming work in the background


def already_processed(event_id):
    ...


def open_ticket(conversation_id, url):
    ...


def assign_ticket(conversation_id, assignee_id):
    ...


def close_ticket(conversation_id, reason=None):
    ...
```

{% endcode %}

```bash
HANDOFF_WEBHOOK_SECRET=your_secret flask --app receiver run --port 8080
```

For local testing, simulate a delivery with the following commands (calculate the signature using the same secret):

```bash
BODY='{"assignee":{"id":"3e8f5a90-0000-0000-0000-000000000004"},"contact":{"id":"a4c1f6b3-0000-0000-0000-000000000005"},"conversation":{"id":"6c2b7c1e-0000-0000-0000-000000000001","url":"https://admin.maiagent.ai/conversations/index?conversationId=6c2b7c1e-0000-0000-0000-000000000001"},"event":"handoff.assigned","id":"9f0e1d2c-0000-0000-0000-000000000006","inbox":{"id":"0f9a3d42-0000-0000-0000-000000000002"},"occurred_at":"2026-09-09T02:30:00.123456+00:00","organization":{"id":"b1d20c77-0000-0000-0000-000000000003"}}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "your_secret" | awk '{print $NF}')
curl -X POST http://localhost:8080/webhooks/handoff \
  -H "Content-Type: application/json" \
  -H "X-MaiAgent-Signature-256: sha256=$SIG" \
  -d "$BODY"
```

## Responses and Retries

* The receiver should respond with a 2xx status code within 10 seconds. The response body can contain anything.
* On timeout, connection failure, or a non-2xx response, the system retries automatically up to 3 times at 60-second intervals. Retry payloads are identical to the original delivery (with the same `id`); deduplicate using the event `id`. Events are sent as separate background tasks, so `handoff.requested` and the immediately following `handoff.assigned` may arrive out of order. Sort them by `occurred_at`.
* Webhook delivery failures do not affect the main handoff workflow or Notification Center and Email notifications.

## External System Takeover After Human Handoff <a href="#external-takeover" id="external-takeover"></a>

If your human support agents reply in your own customer service system rather than the MaiAgent admin console, combine the events on this page, the conversation platform’s “Message Webhook,” and the message API for two-way synchronization during handoff. Create a ticket in your system when you receive `handoff.requested`, receive visitor messages through Message Webhooks, send agent replies using `POST /api/v1/messages/outgoing/`, and call `PATCH /api/v1/conversations/{id}/return-to-ai/` when handling is complete to return control to AI. See [Third-Party Customer Service System Integration (Including Human Handoff)](https://docs.maiagent.ai/tech/serve/customer-service-system-integration#scenarios) in the technical manual for the full workflow, request examples, and considerations. Use Scenario A when the visitor’s channel is connected to your system, or Scenario B when the visitor uses a MaiAgent conversation platform and only the human support agents use your system.

### Integration Workflow <a href="#takeover-flow" id="takeover-flow"></a>

See [Scenario B › Integration Workflow](https://docs.maiagent.ai/tech/serve/customer-service-system-integration#scenario-b-flow) in the technical manual.

### Request Examples <a href="#takeover-examples" id="takeover-examples"></a>

See [Scenario B › Request Examples](https://docs.maiagent.ai/tech/serve/customer-service-system-integration#scenario-b-examples) in the technical manual.

### Considerations <a href="#takeover-notes" id="takeover-notes"></a>

See [Scenario B › Considerations](https://docs.maiagent.ai/tech/serve/customer-service-system-integration#scenario-b-notes) in the technical manual.


---

# 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/handoff-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.
