Skip to content

回调说明 ​

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.modified2131 外部联系人变动,以及 2118 / 1006 群或联系人变动按系统通知刷新资料
instance.online企微连接建立,请求体为 {}记录连接状态
instance.offline企微连接断开,请求体为 {code, message, time}按连接事件处理;只有 -11001 可自动重连,-11002 置为离线并提示,由用户决定是否重新登录
webhook.test手动调用测试接口校验签名、保存测试内容后返回 HTTP 2xx,建议返回 {}

可订阅的事件以可订阅事件列表接口返回为准。不认识的事件也先校验签名、保存原始内容,再返回 2xx,留待后续处理。除了接收掉线通知,也要定期检查账号是否在线。

回调地址要求 ​

先校验签名,再读取消息。把消息存进数据库或可靠的队列后,再返回成功。 发通知、调用其他接口等耗时操作,可以留给后台任务处理。

  • 地址可访问:使用公网 IP 或域名,接收 POST JSON。
  • 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。

删除会立即停止推送,操作前请确认。 重建后同步更新接收服务的验签密钥,再测试通过。仅更换接收地址时不用删除配置。

下一步 ​