Appearance
回调说明
Webhook(消息回调)用来把企微收到的消息发送到你的服务器。例如,客户发来一句「你好」,Eyun 就会向你设置的地址发送一个 POST 请求,你的程序从中读取这句话。
第一次接入,先完成下面三件事:填接收地址 → 测试地址 → 收到一条真实消息。 已经在控制台跑通的,可以直接看如何读取消息。
开始前,先确认企微账号已经登录并保持在线,再按自己的情况继续:
| 你现在的情况 | 怎么继续 |
|---|---|
| 已有公网接收服务 | 按下方三步填写地址、测试并接收真实消息 |
| 还没有程序或公网地址 | 使用可直接运行的接收示例,在同一篇教程里完成准备、配置和测试;跑通后再看本页的消息处理说明 |
三步接上
1. 设置你的接收地址
打开控制台收发测试,填入你自己的接收地址,点击「保存并启用 Webhook」。这个地址必须能从公网访问。
首次创建后,保存页面显示的 Webhook 密钥,并填到接收程序中。它用于核对收到的请求是否来自 Eyun,与调用 API 的 App Token 不是同一把密钥。已有配置继续使用之前保存的密钥。
用 API 设置接收地址
调用设置或更新回调地址,替换下面的地址和凭证:
bash
curl -X POST "{BASE_URL}/webhook/set" \
-H "Authorization: Bearer <你的 App Token>" \
-H "Content-Type: application/json" \
-d '{ "url": "https://your.domain/hook" }'| 示例里的值 | 应该填什么 |
|---|---|
{BASE_URL} | 从控制台文档中心复制完整 API Base URL,保留末尾 /wx-api |
<你的 App Token> | 你在应用凭证页领取并保存的完整 Auth |
url | 你自己的公网接收地址,例如 https://your.domain/hook |
响应 code=0 表示设置成功。首次创建时,保存返回的 data.secret,在接收程序中用于校验签名;更新地址时继续用原密钥。
回调配置属于当前应用,不需要传 appid。本例没有填 events,表示接收全部已支持事件。
2. 测试地址能否收到请求
保持接收程序运行。新建配置时,先勾选「我已保存 Webhook 密钥」,再点击「发送测试投递」。已有配置继续使用原密钥。
检查这两项:
- 你的服务器收到了测试内容
{"hello":"这是一次测试投递"},校验签名并保存后返回成功(HTTP 2xx)。 - 控制台显示测试投递成功,接收程序也没有报错。
测试通过,说明接收地址能用;还要做第 3 步,确认能收到企微消息。
用 API 测试,以及测试失败时看哪里
调用测试投递:
bash
curl -X POST "{BASE_URL}/webhook/test" \
-H "Authorization: Bearer <你的 App Token>" \
-H "Content-Type: application/json" \
-d '{}'API 测试通过的条件:data.ok=true,且 data.httpStatus 为 200~299。后者是你的接收服务器返回的状态码,不能只看测试接口本身的 HTTP 200 或 code=0。测试失败时,按验证与排查检查。
测试请求头为 X-Eyun-Event: webhook.test。测试消息不属于某个企微账号,因此不带 X-Eyun-Instance-Id。
响应中的 data.eventId 和 data.deliveryId 用于查找这次推送记录,data.bodyJson 是发给你的测试内容。
3. 收到第一条真实消息
用另一个企微账号,在客户端给已登录的账号发送控制台显示的验证文字,然后到你的接收服务器查看消息。仅通过 API 联调时,可以发一句「你好」。
收到的请求头是 X-Eyun-Event: message.received。文本消息的 contentType 为 0 或 2,从 content 数组里的 text 字段读取正文。下方有完整的读取说明。
你的服务器读到刚发的相同文字,才算接收成功。 使用本地示例时,按查看收到的消息操作;回到控制台后,再点击「接收端已读到这条真实正文」。
这一步请在企微客户端发消息
用 API 发出的消息只会推送已读回执(contentType 2001),不会推送正文。测试按钮发出的也不是企微聊天消息。
收到推送后怎么处理
收到 message.received 时,推送的请求体就是这条消息本身,文本位于 body.content[].text,没有额外的 data 包装。下面节选一条文本推送,只保留读取消息需要的字段;完整样本见回调字典。
json
{
"id": 1013420,
"messageType": 0,
"contentType": 0,
"roomId": 0,
"fromUserId": 7881300000000002,
"toUserId": 1688850000000001,
"content": [
{
"type": 0,
"text": "你好"
}
]
}这条消息可以这样读:
messageType=0:单聊;fromUserId是发送方,toUserId是接收方。contentType=0:文本消息,将content数组里的各段text拼起来,得到「你好」。- 群聊时
messageType=1,群 ID 取roomId。图片、文件等类型的content读法不同,按类型速查表处理。
请求头里的几个 ID 和签名分别有什么用?
消息内容在请求体里;请求头告诉你消息来自哪个账号、这次推送的编号,以及如何校验签名。
| 请求头 | 用途 |
|---|---|
X-Eyun-Event | 区分消息、联系人变化、上线与掉线等事件 |
X-Eyun-Event-Id | 事件 ID,同一事件重试时保持不变,用于去重 |
X-Eyun-Delivery | 本次推送 ID,每次重试会变化,排查问题时使用 |
X-Eyun-Instance-Id | 产生事件的企微实例 ID,用来区分你登录的账号;测试事件不带此字段 |
X-Eyun-App-Id | 绑定的应用 ID,存在绑定时携带 |
X-Eyun-Signature | 推送签名,按校验签名验证 |
其他事件怎么处理?
| 事件名 | 什么时候推 | 怎么处理 |
|---|---|---|
message.received | 文本、图片、视频等内容类消息 | 按 contentType 查回调字典;2001 已读回执可忽略,不当作新消息 |
friend.added | 好友申请或新增,contentType 为 2357 / 2132 | 按好友申请读取 |
friend.deleted | 外部联系人删除,伴随 2131 | 调同步外部数据判断增删 |
contact.modified | 2131 外部联系人变动,以及 2118 / 1006 群或联系人变动 | 按系统通知刷新资料 |
instance.online | 企微连接建立,请求体为 {} | 记录连接状态 |
instance.offline | 企微连接断开,请求体为 {code, message, time} | 按连接事件处理;只有 -11001 可自动重连,-11002 置为离线并提示,由用户决定是否重新登录 |
webhook.test | 手动调用测试接口 | 校验签名、保存测试内容后返回 HTTP 2xx,建议返回 {} |
可订阅的事件以可订阅事件列表接口返回为准。不认识的事件也先校验签名、保存原始内容,再返回 2xx,留待后续处理。除了接收掉线通知,也要定期检查账号是否在线。
回调地址要求
先校验签名,再读取消息。把消息存进数据库或可靠的队列后,再返回成功。 发通知、调用其他接口等耗时操作,可以留给后台任务处理。
- 地址可访问:使用公网 IP 或域名,接收
POSTJSON。 - 5 秒内确认接收:消息保存成功后返回 HTTP 2xx,建议返回
{}。如果保存失败,就不要返回成功,否则 Eyun 会认为你已经收到了。 - 重复推送只处理一次:用
X-Eyun-Event-Id识别同一事件的重试;用X-Eyun-Instance-Id加消息id识别同一账号的重复消息。消息 ID 不能跨账号单独使用。 - 保留数字精度:
id、syncKey、fromUserId、toUserId、roomId是 int64,解析时不要转成 JavaScript Number 或浮点数。 - 记录处理结果:记录事件名、推送 ID、耗时和结果。排错日志不要直接写入完整消息正文、姓名和手机号。
Node.js 接收与分发示例
下面展示 Node.js + Express 的处理顺序,需要接入你自己的队列和业务代码。queue.push 必须在消息保存成功后才返回,保存失败时抛出异常,不能用内存数组代替。queue、onText 等由你实现,后台任务调用 handle 并处理重复消息和失败重试。想先直接运行,请用完整的 Python 接收示例。
js
import express from 'express'
import JSONbig from 'json-bigint' // 大整数不丢精度
import { verify } from './verify.js' // 见下文「校验签名」
const app = express()
app.post('/hook', express.raw({ type: '*/*' }), async (req, res) => {
const secret = process.env.EYUN_WEBHOOK_SECRET
const signature = req.get('X-Eyun-Signature') ?? ''
const ok = verify(secret, req.body, signature)
if (!ok) return res.status(401).end()
try {
await queue.push({
eventId: req.get('X-Eyun-Event-Id'), // 重试时不变,用它识别重复推送
delivery: req.get('X-Eyun-Delivery'), // 本次推送编号,供排查
instanceId: req.get('X-Eyun-Instance-Id'),
event: req.get('X-Eyun-Event'),
body: JSONbig.parse(req.body.toString('utf8')),
})
} catch {
return res.status(500).end() // 保存失败,让 Eyun 按规则重试
}
res.json({}) // 已保存到队列,后续工作交给后台任务
})
// 联系人类事件,按 contentType 查回调字典
const CONTACT_EVENTS = [
'friend.added',
'friend.deleted',
'contact.modified',
]
// 队列里逐条处理
function handle({ event, body }) {
if (event === 'message.received') {
switch (Number(body.contentType)) {
case 0: case 2:
return onText(body.content.map((c) => c.text).join(''))
case 14: case 101: case 16: case 23:
case 103: case 15: case 20:
// 用 content.id 与 aesKey 调 CDN 下载
return onMedia(body)
case 2001:
return // 已读回执,可忽略
default:
// 其它类型原样保存,读法查回调字典
return saveRaw(body)
}
}
if (CONTACT_EVENTS.includes(event)) return onContact(body)
if (event === 'instance.offline') return alert(body)
if (event === 'webhook.test') return
if (event === 'instance.online') return
return saveRaw(body) // 其它事件原样保存,由业务判断
}校验签名
签名用于确认请求来自 Eyun,内容没有被改动。可直接运行的接收示例已做好签名校验;使用示例时,填入你保存的 Webhook 密钥即可。
自己实现时,用首次创建回调保存的 secret,对收到的原始请求体字节计算 HMAC-SHA256。给结果加上 sha256= 前缀,再与请求头 X-Eyun-Signature 做常量时间比较;不一致就拒绝请求。
先校验签名,再解析 JSON。不要把解析后的 JSON 重新转成字符串来验签,空格或字段顺序变化都会导致校验失败。
Node.js 与 Python 验签函数
js
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verify(secret, rawBody, header) {
const hmac = createHmac('sha256', secret)
const digest = hmac.update(rawBody).digest('hex')
const expected = 'sha256=' + digest
const a = Buffer.from(expected), b = Buffer.from(header)
return a.length === b.length && timingSafeEqual(a, b)
}python
import hmac, hashlib
def verify(secret: str, raw_body: bytes, header: str) -> bool:
digest = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
expected = 'sha256=' + digest
return hmac.compare_digest(expected, header)推送规则
| 情况 | 系统怎么处理 |
|---|---|
| 接收服务返回 HTTP 2xx | 本次推送成功,建议响应 {} |
| 接收服务超时 | 默认每次超时 5 秒,可按回调配置调整 |
| 推送失败 | 默认总共尝试 5 次,即首次加 4 次重试;相邻重试等待 1 分钟、5 分钟、30 分钟、2 小时。实际重试时间可能稍晚 |
| 多条推送到达 | 不保证顺序,消息可按 sendTime 排序 |
| 连续失败 | 默认连续失败达到 10 次,并且持续至少 30 分钟时自动暂停推送(熔断);具体以回调配置为准。修好接收服务后,到企业微信控制台的 Webhooks 页手动恢复 |
暂停期间的事件不会补发
回调状态为 paused(暂停)或 circuit_open(熔断)期间,事件直接丢弃,不积压。恢复后只接收新事件。
验证与排查
| 遇到的问题 | 先这样检查 |
|---|---|
测试失败,data.ok=false | 查看 data.httpStatus 和接收服务日志;确认公网地址、/hook 路径与 POST 处理都正确,服务仍在运行 |
| 测试通过,却收不到真实文本 | 确认账号在线,用另一个企微账号从客户端发消息;调用查询当前回调配置,检查 url、订阅包含 message.received,status 为 active |
收到 2001,没有消息正文 | 这是已读回执。改用另一个账号从客户端手动发消息测试 |
| 验签失败 | 核对保存的原 secret,确认使用原始请求体字节;更新回调地址不会更换密钥 |
| 同一条消息重复收到 | 先用 X-Eyun-Event-Id 检查是否重试;再用实例 ID 加消息 id 检查是否处理过。X-Eyun-Delivery 每次重试会变,不适合去重 |
| 接收或处理耗时长 | 消息保存成功后先返回 2xx,耗时操作交给后台任务;保存失败时不要返回成功 |
| 账号掉线,没有收到事件 | 检查事件订阅与回调状态,并用业务接口探测,按掉线检测与恢复处理 |
更换地址或只接收部分事件
每个应用只有一个回调地址。再次调用设置或更新回调地址会更新配置,接收服务继续使用已保存的 secret;切换后重新测试推送。
只接收消息时,events 填 ["message.received"];只处理文本时,还要在接收端筛选 contentType 为 0 或 2。需要其他事件时,从可订阅事件列表选择;不填 events 表示订阅全部事件。
Webhook 密钥丢了怎么办?
更新已有回调配置不会返回或更换 secret。可以联系对接人协助恢复;也可以删除回调配置后重新设置,获取新 data.secret。
删除会立即停止推送,操作前请确认。 重建后同步更新接收服务的验签密钥,再测试通过。仅更换接收地址时不用删除配置。