---
url: https://docs.oakxgen.ai/zh-Hant-TW/guide/agents/webhook-integration.md
description: >-
  自家 App 或客服系統要接上 OakXgen 的 AI，走通用 Webhook：客人訊息連同對話歷史 POST 進來，AI 的回覆稍後 POST 到你的
  callback URL。這篇是給工程師的完整格式說明。
---

**通用 Webhook** 給沒有現成串接的系統用，例如自家 App、官網客服視窗、自建的客服系統。
流程是非同步的：

1. 你的系統把客人的訊息 POST 到 OakXgen 的**接收網址**，我們立刻回 `200`。
2. AI 想好回覆之後，POST 到你設定的 **Callback URL**。

兩個方向都用同一把**驗簽金鑰**簽名。Channel agent 與 Skill agent 都能用，差別見文末。

## 在後台啟用

到 AI 代理的**串接**分頁，最下方的**通用 Webhook** 按**啟用 Webhook**，會立刻產生：

* **接收網址（給對方打）**——格式是 `https://webhook.oakxgen.ai/agent/<webhook_id>/`，
  每個 AI 只有一條。
* **驗簽金鑰**——**只會顯示這一次**，請當下複製存好。弄丟了只能按**重新產生金鑰**，
  舊的會立刻失效。

接著填 **Callback URL（AI 回覆送到這裡）** 並儲存。**還沒填 Callback URL 之前，
打進來的請求會回 `400`**：

```json
{"error": "這個 webhook 還沒設定 callback URL，AI 的回覆沒有地方送"}
```

之所以直接擋下而不是默默收下，是因為收了也送不出回覆——與其讓訊息石沉大海，
不如讓串接的工程師第一時間就看到原因。

Callback URL 必須是對外公開的 **`https://`** 網址（callback 帶的是客人的對話內容，不接受明文的
`http://`）。**內網 IP、`localhost`、雲端 metadata 位址一律不允許**，會看到「callback URL 不允許」
開頭的錯誤。你的端點如果轉址，轉址目標也必須是 https，否則那次投遞算失敗。

## 簽章

請求與 callback 的簽章算法相同，都帶兩個 header：

```
X-OakXgen-Timestamp: <現在的 unix 秒數>
X-OakXgen-Signature: base64( HMAC-SHA256( 驗簽金鑰, "<timestamp>." + 原始 request body ) )
```

**時間也簽進去**：跟我們的時鐘差超過 **5 分鐘**的請求一律拒收。這樣就算有人截到一個合法的
請求，也沒辦法事後原樣重送。你收 callback 時也請做一樣的檢查。

**簽的是原始 body 的位元組**，不是重新序列化過的 JSON。你收 callback 驗簽時，
請拿收到的 raw body 計算，不要先 parse 再 dump，空白或欄位順序一變簽章就對不上。

Python：

```python
import base64, hashlib, hmac, json, time, requests

body = json.dumps(payload, ensure_ascii=False).encode()
timestamp = str(int(time.time()))
message = timestamp.encode() + b"." + body
signature = base64.b64encode(hmac.new(SECRET.encode(), message, hashlib.sha256).digest()).decode()
requests.post(INBOUND_URL, data=body, headers={
    "Content-Type": "application/json",
    "X-OakXgen-Timestamp": timestamp,
    "X-OakXgen-Signature": signature,
})
```

Node.js：

```js
import crypto from 'node:crypto'

const body = JSON.stringify(payload)
const timestamp = String(Math.floor(Date.now() / 1000))
const signature = crypto
  .createHmac('sha256', SECRET)
  .update(`${timestamp}.${body}`)
  .digest('base64')
await fetch(INBOUND_URL, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-OakXgen-Timestamp': timestamp,
    'X-OakXgen-Signature': signature
  },
  body
})
```

驗 callback（Python）：

```python
def verify(raw_body: bytes, headers) -> bool:
    timestamp = headers.get("X-OakXgen-Timestamp", "")
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
        return False
    expected = base64.b64encode(
        hmac.new(SECRET.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).digest()
    ).decode()
    return hmac.compare_digest(expected, headers.get("X-OakXgen-Signature", ""))
```

簽章錯誤、沒帶簽章、沒帶時間或時間差超過 5 分鐘都會回 `403`。請確認你的伺服器有校時（NTP）。

## 送訊息進來

```http
POST https://webhook.oakxgen.ai/agent/<webhook_id>/
Content-Type: application/json
X-OakXgen-Timestamp: 1790000000
X-OakXgen-Signature: ...

{
  "user_id": "u-123",
  "text": "請問出貨要幾天？",
  "display_name": "王小明",
  "history": [
    {"role": "user", "text": "你好"},
    {"role": "assistant", "text": "您好，請問需要什麼協助？"}
  ]
}
```

| 欄位 | 必填 | 說明 |
|---|---|---|
| `user_id` | ✅ | 你那邊的客人 ID（字串，最長 128 字）。**同一個客人每次都要一樣**，AI 靠它接續表單、情境等流程狀態。 |
| `text` | 二擇一 | 一則文字訊息。 |
| `messages` | 二擇一 | 一次送多則，例如文字加圖片，見下方。 |
| `history` | ✅ | **這一則之前**的對話，不含這一則。第一句給空陣列 `[]`。 |
| `display_name` | | 客人名稱，AI 稱呼客人時會用。 |
| `message_id` | | 你那邊的訊息 ID。逾時重送同一則時帶一樣的值，AI 還沒回覆之前收到的重複訊息會被忽略。 |
| `reset` | | `true` 代表重新開始對話，見下方。 |

一次送多則：

```json
{
  "user_id": "u-123",
  "messages": [
    {"type": "text", "text": "這件有其他顏色嗎？"},
    {"type": "image", "url": "https://example.com/photo.jpg"}
  ],
  "history": []
}
```

`messages[].type` 可以是 `text`、`image`、`audio`、`video`、`file`；非文字的一定要帶 `url`。

### 為什麼 history 每次都要帶

OakXgen **不保存 webhook 的對話內容**，只保存流程狀態（表單填到哪、在哪個情境、
是否暫停中）。對話的正本在你的系統裡，理由有二：

* **轉真人之後，真人客服回的話發生在你那邊**，我們看不到。AI 接回來時如果看不到
  真人答應過客人什麼，就會講出矛盾的話。
* 你可以決定要給 AI 看多少歷史、要不要濾掉敏感內容。

`history` 每一項：

```json
{"role": "user", "text": "你好"}
{"role": "assistant", "text": "您好，請問需要什麼協助？"}
{"role": "human", "text": "我是客服小美，幫您查一下訂單"}
{"role": "user", "type": "image", "url": "https://example.com/photo.jpg"}
```

* `role`：`user` 是客人、`assistant` 是 AI、`human` 是你們的真人客服。
* `type`：預設 `text`，也可以是 `image`、`audio`、`video`、`file`（帶 `url`）。
* 最多取最後 200 則。

少帶 `history` 會回 `400`：

```json
{"error": "缺少 history（對話歷史陣列，第一句就給空陣列 []）"}
```

### 重新開始對話

```json
{"user_id": "u-123", "reset": true}
```

清掉這位客人的流程狀態（表單、情境、暫停），**也會解除轉真人**，下一則訊息 AI 重新接手。
只送 `reset` 不用帶 `history`。

### 回應

| 狀態 | 意思 |
|---|---|
| `200 {"status": "ok"}` | 收到了。回覆稍後送到 callback。 |
| `400 {"error": "..."}` | 格式錯誤，`error` 會寫是哪個欄位。 |
| `403` | 簽章錯誤，或時間差超過 5 分鐘。 |
| `404` | 接收網址不存在，或 webhook 已停用。 |

AI 代理被關閉時也會回 `200`，但不會有 callback——避免你的系統因為我們這邊關掉 AI
而一直重送。

## 收 callback

我們 POST 到你的 Callback URL，帶這三個 header（簽法見上方[簽章](#簽章)）：

```
X-OakXgen-Timestamp: <unix 秒數>
X-OakXgen-Signature: base64(HMAC-SHA256(驗簽金鑰, "<timestamp>." + raw body))
X-OakXgen-Webhook-ID: <webhook_id>
```

你的端點回 `2xx` 就算送達；`4xx`、`5xx` 或 15 秒內沒回應算失敗，後台的串接卡片會顯示
最後一次失敗的原因。**我們不會自動重送**。

### AI 的回覆

```json
{
  "event": "messages",
  "reason": "reply",
  "user_id": "u-123",
  "agent_id": 1,
  "messages": [
    {"type": "text", "text": "一般 3 個工作天內出貨。"},
    {
      "type": "cards",
      "alt_text": "推薦商品",
      "cards": [
        {
          "title": "經典帆布包",
          "description": "NT$ 890",
          "image_url": "https://example.com/bag.jpg",
          "buttons": [{"label": "看商品", "url": "https://example.com/p/1"}]
        }
      ],
      "quick_replies": [{"label": "查訂單", "data": "查訂單"}]
    }
  ]
}
```

`messages` 是一串「泡泡」，照順序顯示就是 AI 想講的樣子：

* `{"type": "text", "text": "..."}`
* `{"type": "image", "img_url": "...", "preview_img_url": "..."}`
* `{"type": "cards", "cards": [...], "alt_text": "..."}`——`alt_text` 是不支援卡片時的替代文字。
* **最後一顆泡泡**可能帶 `quick_replies`：`[{"label": 顯示文字, "data": 按下後要當成客人訊息送回來的字}]`。

怎麼畫由你決定。不支援卡片的話，顯示 `alt_text` 或把 `title`、`description` 轉成文字即可。

`reason`：

* `reply`——回覆客人剛送的訊息。
* `follow_up`——客人一陣子沒回，AI 主動追問（只有 Channel agent 開了**主動追問**才會有）。

### 轉真人（只有 Channel agent）

在**通用 Webhook** 卡片勾了**對方有真人客服：轉真人時通知對方，由對方的人接手**，
轉真人時會收到：

```json
{"event": "handoff", "user_id": "u-123", "agent_id": 1}
```

收到之後這位客人的訊息請交給你們的真人客服，AI 會一直暫停，**不會自己恢復**——
這段期間打進來的訊息我們會收下（回 `200`）但不處理。真人處理完要讓 AI 接回來，送
`{"user_id": "u-123", "reset": true}`，之後客人再傳訊息就由 AI 回覆。記得把真人客服
講過的話以 `role: "human"` 放進 `history`，AI 才知道真人答應過客人什麼。

**沒勾的話**，轉真人時不會通知你，也不會傳任何話給客人，AI 對這位客人暫停 30 分鐘
（已讀不回），之後自動恢復。細節見[轉真人怎麼觸發](./handoff.md)。

## Channel agent 與 Skill agent 的差別

| | Channel agent | Skill agent |
|---|---|---|
| 表單、情境等流程 | ✅ | — |
| 主動追問（`reason: follow_up`） | ✅ | — |
| 轉真人（`event: handoff`） | ✅ | — |
| 看得懂的訊息 | 文字、圖片 | 只有文字（圖片等會被略過） |
| 回覆格式 | 泡泡 | 泡泡 |

Skill agent 要逐字串流（打字機效果）的話，webhook 做不到，請改用[對話 API](./api-conversation.md)。
