接收 Webhook
Webhook 会在事件发生时通知你的服务器,省去轮询。需要含 Webhook 的套餐(入门版及以上)。
添加地址
在工作台打开 开发者 → Webhook → 添加地址:
- 接收地址:公网上的 HTTPS 地址。内网地址、
localhost之类会被拒绝;每次投递时还会重新检查域名解析到的地址。 - 订阅的事件:选你需要的(见下表)。
- 复制签名密钥(
whsec_…),它只显示一次;更换密钥会生成新密钥,旧密钥立即失效。
每个工作区最多 10 个地址。发送测试事件 会投递一个 ping 事件,不受订阅的事件限制。
事件
| 事件 | 何时触发 | data |
|---|---|---|
conversation.created | 访客发起会话 | { conversation } |
conversation.assigned | 会话被分配或转接 | { conversation } |
conversation.closed | 会话结束(客服结束、API 结束,或闲置 30 分钟自动结束) | { conversation } |
message.created | 访客、客服或集成发送消息(内部备注不会发出) | { message, conversation } |
offline_message.created | 没人在线时访客留下留言 | { offline_message } |
visitor.identified | 获得了访客的姓名、邮箱或电话 | { visitor } |
visitor.deleted | 站长或管理员删除了某个访客的全部数据(见数据请求),请同时删除你那边的副本 | { visitor: { id, site_id } } |
每次投递都是一个 POST 请求,请求体类似:
json
{
"id": "0192f2c1-7d3a-7c1e-9b0e-4f5a6b7c8d9e",
"type": "message.created",
"created_at": "2026-10-25T12:00:00Z",
"data": {
"message": { "id": "0192f2c1-…", "sender": { "type": "visitor" }, "content_type": "text", "text": "Do you ship to Spain?", "created_at": "…" },
"conversation": { "id": "0192f2b8-…", "site_id": "…", "visitor_id": "…", "status": "waiting", "assigned_member_id": null, "started_at": "…" }
}
}并带有这些请求头:
| 请求头 | |
|---|---|
X-Signature | t=<Unix 秒>,v1=<签名>(见下文) |
X-SealReply-Event | 事件类型 |
X-SealReply-Event-Id | 事件的 id,重试和手动重发时不变 |
X-SealReply-Delivery | 本次投递的 ID(排查问题时用) |
校验签名
v1 是用你的密钥对 "<t>.<原始请求体>" 计算的 HMAC-SHA256(十六进制)。请在解析 JSON 之前用原始请求体校验,并拒绝与当前时间相差超过 5 分钟的时间戳:
js
// Verify a SealReply webhook in Node.js (18+), with no dependencies.
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
/**
* header: the X-Signature request header ("t=1761390000,v1=5f2b…").
* rawBody: the request body exactly as received (a Buffer or string), not re-encoded JSON.
*/
export function verifySignature(secret, header, rawBody, now = Date.now() / 1000) {
const parts = Object.fromEntries((header ?? "").split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
if (!parts.v1 || !Number.isInteger(t) || Math.abs(now - t) > TOLERANCE_SECONDS) return false;
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.`).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Try it: SEALREPLY_WEBHOOK_SECRET=whsec_… SIGNATURE='t=…,v1=…' node verify-webhook.mjs < body.json
if (import.meta.url === `file://${process.argv[1]}`) {
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
const ok = verifySignature(process.env.SEALREPLY_WEBHOOK_SECRET, process.env.SIGNATURE, Buffer.concat(chunks));
console.log(ok ? "ok" : "invalid");
process.exit(ok ? 0 : 1);
}py
"""Verify a SealReply webhook in Python 3.10+, with the standard library only."""
import hashlib
import hmac
import os
import sys
import time
TOLERANCE_SECONDS = 5 * 60
def verify_signature(secret: str, header: str, raw_body: bytes, now: float | None = None) -> bool:
"""header: the X-Signature request header; raw_body: the request body as received."""
parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
try:
t = int(parts["t"])
signature = parts["v1"]
except (KeyError, ValueError):
return False
if abs((now or time.time()) - t) > TOLERANCE_SECONDS:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
# Try it: SEALREPLY_WEBHOOK_SECRET=whsec_… SIGNATURE='t=…,v1=…' python3 verify_webhook.py < body.json
if __name__ == "__main__":
ok = verify_signature(os.environ["SEALREPLY_WEBHOOK_SECRET"], os.environ["SIGNATURE"], sys.stdin.buffer.read())
print("ok" if ok else "invalid")
sys.exit(0 if ok else 1)php
<?php
// Verify a SealReply webhook in PHP 7.4+, with no extensions beyond the defaults.
const SEALREPLY_TOLERANCE_SECONDS = 300;
/**
* $header: the X-Signature request header ($_SERVER['HTTP_X_SIGNATURE']).
* $rawBody: the request body as received (file_get_contents('php://input')).
*/
function sealreply_verify_signature(string $secret, string $header, string $rawBody, ?int $now = null): bool
{
$parts = [];
foreach (explode(',', $header) as $part) {
$pair = explode('=', $part, 2);
if (count($pair) === 2) {
$parts[$pair[0]] = $pair[1];
}
}
if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) {
return false;
}
if (abs(($now ?? time()) - (int) $parts['t']) > SEALREPLY_TOLERANCE_SECONDS) {
return false;
}
$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1']);
}
// Try it: SEALREPLY_WEBHOOK_SECRET=whsec_… SIGNATURE='t=…,v1=…' php verify-webhook.php < body.json
if (PHP_SAPI === 'cli' && realpath($argv[0] ?? '') === __FILE__) {
$ok = sealreply_verify_signature(getenv('SEALREPLY_WEBHOOK_SECRET'), getenv('SIGNATURE'), stream_get_contents(STDIN));
echo $ok ? "ok\n" : "invalid\n";
exit($ok ? 0 : 1);
}go
//go:build ignore
// Verify a SealReply webhook in Go, with the standard library only.
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
)
const tolerance = 5 * time.Minute
// VerifySignature checks the X-Signature header against the raw request body.
func VerifySignature(secret, header string, rawBody []byte, now time.Time) bool {
parts := map[string]string{}
for _, p := range strings.Split(header, ",") {
if k, v, ok := strings.Cut(p, "="); ok {
parts[k] = v
}
}
t, err := strconv.ParseInt(parts["t"], 10, 64)
if err != nil || parts["v1"] == "" {
return false
}
if d := now.Sub(time.Unix(t, 0)); d > tolerance || d < -tolerance {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(parts["t"] + "."))
mac.Write(rawBody)
return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(parts["v1"]))
}
// Try it: SEALREPLY_WEBHOOK_SECRET=whsec_… SIGNATURE='t=…,v1=…' go run verify_webhook.go < body.json
func main() {
body, _ := io.ReadAll(os.Stdin)
if VerifySignature(os.Getenv("SEALREPLY_WEBHOOK_SECRET"), os.Getenv("SIGNATURE"), body, time.Now()) {
fmt.Println("ok")
return
}
fmt.Println("invalid")
os.Exit(1)
}我们的测试会按真实投递的方式签名,再逐个运行这些示例,照抄即可用。
尽快响应,处理重复
- 请在 10 秒内返回任意 2xx,其他情况都算失败。耗时的处理放到响应之后做(例如先放进队列)。
- 失败后分别在 1 分钟、5 分钟、30 分钟、2 小时、6 小时、24 小时后重试。连续失败 3 天的地址会被自动停用,修好后在工作台重新启用即可。
- 极少数情况下同一事件会送达两次,事件到达的顺序也可能和发生顺序不同。请按事件
id去重,并以created_at和会话的status为准,而不是到达顺序。
投递日志
开发者 → Webhook → 投递日志 可以查看 30 天内的每次投递:状态、响应码或错误、耗时、下次重试时间和发送的内容。重发 会立即用同一个 id 再发一次。