Skip to content

接收 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-Signaturet=<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 再发一次。