Webhook 事件回调

服务端 API 更新于 2026-09-21 阅读 11

阅读指引:本篇是《开放平台服务端 API 指南》的第 7 部分。接口基础地址、通用约定与各接口请求限制,请先阅读《服务端 API 概览》。

10. 通过 Webhook 接收消息、卡片操作、组织和群事件

平台会向管理员配置的 webhook_url 异步投递用户上行消息、模板卡片操作菜单点击、组织目录失效通知和群应用变化通知。应用必须处于已发布状态,且 Webhook 状态必须是 verified;组织事件还要求 contact:organization:read,群事件还要求 group:app 以及与目标群匹配的当前关系状态。

你需要完成的四件事在应用后端部署 POST 接口;使用 sign_key 对原始请求体进行验签;正确响应 URL 验证 challenge;按 event_id 分流并处理普通消息、模板卡片操作、组织和群同步任务。

10.1 配置和启用顺序

  1. 从管理员处安全取得当前应用的 app_keyapp_secretsign_key。Webhook 事件使用开发者已知的 app_key 标识目标应用;只有 sign_key 用于 Webhook 签名。
  2. 先部署本章的验签和 webhook.url.verify 响应逻辑,再让管理员填写 webhook_url
  3. 公网 Webhook URL 必须使用 HTTPS;HTTP 只允许平台策略认可的私网地址。URL 不得包含用户名、密码、query 或 fragment。公网应用应使用受信任 CA 签发、域名匹配且在有效期内的证书,并保证完整证书链可用。
  4. 创建应用时即使填写 URL 也不会自动探测;创建完成后,由管理员在应用详情页保存 URL 或点击“重新验证”。
  5. 管理员看到状态为 verified 后,用真实客户端发送一条文本消息;使用模板卡片操作时,再发送一张带 actionMenu 的测试卡片并点击菜单,完成端到端验收。
用户发送受支持消息、点击模板卡片操作菜单,或授权组织目录/群应用发生变化
  → 平台生成事件并使用 sign_key 签名
  → 平台 POST 到已验证的 webhook_url
  → 应用验签、幂等落库并在 5 秒内返回 2xx

10.2 请求头和请求签名

请求头 值与用途
Content-Type 固定为 application/json
X-Matrix-Event-ID 事件稳定标识,与 JSON 的 event_id 相同;同一事件的 HTTP 重试期间不变
X-Matrix-Request-ID 单次 HTTP 请求标识;每次重试都会变化,只用于排障
X-Matrix-Timestamp 本次 HTTP 请求生成时间,Unix 毫秒十进制字符串
X-Matrix-Nonce 本次 HTTP 请求随机串;每次重试都会变化
X-Matrix-Signature-Method 固定为 HMAC-SHA256
X-Matrix-Signature 64 位小写 hex HMAC-SHA256 结果

必须先读取未经解析、未经格式化的原始 HTTP body 字节,再计算签名。canonical bytes 精确定义如下,换行是单个 ASCII LF,即 0x0A

canonical = UTF8(X-Matrix-Timestamp) || 0x0A
          || UTF8(X-Matrix-Nonce)     || 0x0A
          || raw_request_body

signature = lowercase_hex(HMAC_SHA256(key = UTF8(sign_key), data = canonical))

应用必须使用恒定时间比较签名,校验 X-Matrix-Event-ID == body.event_id,并建议拒绝与本机时间相差超过 5 分钟的请求。不要对 JSON 重新序列化后验签;字段顺序、空格或转义变化都会得到不同结果。

10.3 URL 验证消息和必须返回的内容

验证消息与普通消息显著分离:event_type=webhook.url.verifymessage.type=webhook_verify_textmessage.seq_id=0message.sender_id="0"。收到后不要写入用户会话,也不要触发机器人业务逻辑。

{
  "event_id": "b6eb138d-1c86-4f03-a253-9d71255e5a4c",
  "event_type": "webhook.url.verify",
  "version": 1,
  "app_key": "svc_app_xxx",
  "created_at": 1787798400000,
  "message": {
    "message_id": "webhook_verify_b6eb138d-1c86-4f03-a253-9d71255e5a4c",
    "seq_id": 0,
    "sender_id": "0",
    "type": "webhook_verify_text",
    "sent_at": 1787798400000,
    "payload": {
      "content": "MATRIX_WEBHOOK_VERIFY",
      "challenge": "9a24d55b4dc24c5ebf0d5944ce4cf738"
    }
  }
}
验证请求字段 JSON 类型 固定值/含义
event_id string 本次验证事件唯一标识,同时出现在 X-Matrix-Event-ID
event_type string 固定为 webhook.url.verify
version integer 当前固定为 1
app_key string 正在验证 Webhook 的应用公开标识。
created_at / message.sent_at integer 平台生成验证事件的 Unix 毫秒时间戳。
message.message_id string 验证消息标识,只能用于排障,不作为用户消息保存。
message.seq_id / message.sender_id integer / string 分别固定为数字 0 和字符串 "0"
message.type string 固定为 webhook_verify_text
message.payload.content string 固定为 MATRIX_WEBHOOK_VERIFY
message.payload.challenge string 本次验证随机挑战值;必须原样放入响应并参与响应签名。

仅返回 HTTP 200 不算验证成功。应用必须原样返回 challenge,并使用同一个 sign_key 对响应签名:

response_canonical = UTF8(response_timestamp) || 0x0A
                   || UTF8(response_nonce)     || 0x0A
                   || UTF8(challenge)

response_signature = lowercase_hex(
  HMAC_SHA256(key = UTF8(sign_key), data = response_canonical)
)

{
  "code": 0,
  "challenge": "9a24d55b4dc24c5ebf0d5944ce4cf738",
  "timestamp": "1787798400123",
  "nonce": "app_generated_random_nonce",
  "signature": "64-character-lowercase-hex-hmac-sha256"
}
  • timestampnonce 由应用生成,必须是非空字符串;建议使用当前 Unix 毫秒和至少 16 字节安全随机数的 hex。
  • 响应 body 不能超过 1024 字节,HTTP 状态必须是 2xx,code 必须等于数字 0
  • challenge、签名或 JSON 任一不匹配,后台状态都会变为 failed
响应字段 JSON 类型 必填 含义与约束
code integer 验证成功固定为数字 0
challenge string 原样返回请求 message.payload.challenge,不得解码、截断或生成新值。
timestamp string 应用生成响应的 Unix 毫秒时间戳,以十进制字符串返回。
nonce string 应用为本次响应生成的非空随机串;建议至少使用 16 字节安全随机数。
signature string 按上述响应 canonical 规则计算的 HMAC-SHA256,固定为 64 位小写十六进制字符串。

以下示例展示验签与事件分派,不是可直接上线的完整应用。标记 TODO 的持久化、业务授权及去重逻辑须由接入方实现;示例默认拒绝确认尚未持久化的业务事件。建议以应用标识与 event_id 建唯一键,在同一事务中保存事件和待处理任务,重复事件直接返回 2xx。验签通过后还应使用共享存储原子记录 nonce;重放请求不得再次产生业务副作用。消息业务异步执行,Webhook 应在 5 秒内返回。

10.4 JavaScript 处理骨架

import crypto from "node:crypto"
import express from "express"

const app = express()
const signKey = process.env.MATRIX_SIGN_KEY
const expectedAppKey = process.env.MATRIX_APP_KEY
if (!signKey || !expectedAppKey) throw new Error("请配置应用凭据")

async function saveMessageIdempotently(event) {
  // TODO: 使用 event.event_id 唯一键,在一个数据库事务中保存消息。
  throw new Error("请先实现持久化、授权校验和幂等处理")
}

async function processTemplateCardAction(event) {
  // TODO: 校验 task_id、operator_id 和 action_key,再按当前业务状态执行或覆盖。
  // HTTP 重试复用 event_id;用户重复点击则会产生新的 event_id。
  throw new Error("请先实现持久化、授权校验和幂等处理")
}

async function scheduleDirectorySyncIdempotently(event) {
  // TODO: 使用 event.event_id 唯一键记录同步任务;异步调用 organization/sync-state。
  throw new Error("请先实现持久化、授权校验和幂等处理")
}

async function scheduleGroupSyncIdempotently(event) {
  // TODO: 使用 event.event_id 唯一键记录任务;异步调用 groups/changes,不能把 Webhook 当作最终状态。
  throw new Error("请先实现持久化、授权校验和幂等处理")
}

function hmacHex(parts) {
  const hmac = crypto.createHmac("sha256", Buffer.from(signKey, "utf8"))
  for (const part of parts) hmac.update(part)
  return hmac.digest("hex")
}

function equalHex(left, right) {
  if (!/^[0-9a-f]{64}$/.test(left || "") || !/^[0-9a-f]{64}$/.test(right || "")) return false
  return crypto.timingSafeEqual(Buffer.from(left, "hex"), Buffer.from(right, "hex"))
}

app.post("/matrix/webhook", express.raw({ type: "application/json", limit: "64kb" }), async (req, res, next) => {
  try {
  const rawBody = req.body
  if (!Buffer.isBuffer(rawBody)) return res.status(400).end()
  const timestamp = req.get("X-Matrix-Timestamp") || ""
  const nonce = req.get("X-Matrix-Nonce") || ""
  const received = req.get("X-Matrix-Signature") || ""
  if (req.get("X-Matrix-Signature-Method") !== "HMAC-SHA256" ||
      !/^\d{13}$/.test(timestamp) || !nonce ||
      Math.abs(Date.now() - Number(timestamp)) > 300000) return res.status(401).end()
  const expected = hmacHex([Buffer.from(timestamp + "\n" + nonce + "\n", "utf8"), rawBody])
  if (!equalHex(received, expected)) return res.status(401).json({ code: 401 })

  let event
  try { event = JSON.parse(rawBody.toString("utf8")) }
  catch { return res.status(400).json({ code: 400 }) }
  if (req.get("X-Matrix-Event-ID") !== event.event_id) return res.status(400).json({ code: 400 })
  if (!event.event_id || event.app_key !== expectedAppKey) return res.status(400).json({ code: 400 })

  if (event.event_type === "webhook.url.verify") {
    const challenge = event.message?.payload?.challenge || ""
    const responseTimestamp = Date.now().toString()
    const responseNonce = crypto.randomBytes(16).toString("hex")
    const signature = hmacHex([Buffer.from(`${responseTimestamp}\n${responseNonce}\n${challenge}`, "utf8")])
    return res.json({ code: 0, challenge, timestamp: responseTimestamp, nonce: responseNonce, signature })
  }

  if (event.event_type === "message.created") {
    // 当前结构使用 event.occurred_at;文本位于 message.payload.text。
    await saveMessageIdempotently(event)
    return res.sendStatus(204)
  }
  if (event.event_type === "message.template_card.action") {
    await processTemplateCardAction(event)
    return res.sendStatus(204)
  }
  if (event.event_type === "organization.department.changed" ||
      event.event_type === "organization.member.changed") {
    // 通知只是失效提示;不要在回调内执行全量同步。
    await scheduleDirectorySyncIdempotently(event)
    return res.sendStatus(204)
  }
  if (["group.app.added", "group.app.removed", "group.info.changed", "group.member.changed"].includes(event.event_type)) {
    await scheduleGroupSyncIdempotently(event)
    return res.sendStatus(204)
  }
  // 未知事件返回 2xx,避免平台进行没有意义的重试。
  return res.sendStatus(204)
  } catch (err) { next(err) }
})
app.use((err, req, res, next) => res.status(500).json({ code: 500 }))

app.listen(3000)

框架配置如果全局 JSON middleware 先于该路由运行,req.body 会变成对象并丢失原始字节,验签必然失败。Webhook 路由必须先使用 raw body middleware。

10.5 Go 标准库处理骨架

package main

import (
  "crypto/hmac"
  "crypto/rand"
  "crypto/sha256"
  "encoding/hex"
  "encoding/json"
  "errors"
  "io"
  "log"
  "net/http"
  "os"
  "strconv"
  "time"
)

var signKey = []byte(os.Getenv("MATRIX_SIGN_KEY"))
var expectedAppKey = os.Getenv("MATRIX_APP_KEY")

type webhookEvent struct {
  EventID    string `json:"event_id"`
  EventType  string `json:"event_type"`
  Version    int    `json:"version"`
  AppKey     string `json:"app_key"`
  OccurredAt int64  `json:"occurred_at"` // 普通消息、卡片操作和组织事件
  CreatedAt  int64  `json:"created_at"`  // 仅 URL 验证事件
  Message   struct {
    MessageID string `json:"message_id"`
    SeqID     int32  `json:"seq_id"`
    SenderID  string `json:"sender_id"`
    Type      string `json:"type"`
    SentAt    int64  `json:"sent_at"`
    Payload struct {
      Text        string `json:"text"`
      Content     string `json:"content"`
      Challenge   string `json:"challenge"`
      FileName    string `json:"file_name"`
      Size        int64  `json:"size"`
      DurationMS  int64  `json:"duration_ms"`
      ResourceID  string `json:"resource_id"`
      DownloadURL string `json:"download_url"`
      ExpiresAt   int64  `json:"expires_at"`
    } `json:"payload"`
  } `json:"message"`
  Action *struct {
    MessageID  string `json:"message_id"`
    OperatorID string `json:"operator_id"`
    TaskID     string `json:"task_id"`
    CardType   string `json:"card_type"`
    ActionKey  string `json:"action_key"`
  } `json:"action"`
  Department *struct {
    ID *string `json:"id"`
  } `json:"department"`
  Member *struct {
    UserID *string `json:"user_id"`
  } `json:"member"`
  Group *struct {
    GroupID         string `json:"group_id"`
    RelationVersion int64  `json:"relation_version"`
    GroupUpdatedAt  int64  `json:"group_updated_at"`
    MemberUpdatedAt int64  `json:"member_updated_at"`
  } `json:"group"`
}

func signRequest(timestamp, nonce string, rawBody []byte) string {
  mac := hmac.New(sha256.New, signKey)
  mac.Write([]byte(timestamp))
  mac.Write([]byte{0x0A})
  mac.Write([]byte(nonce))
  mac.Write([]byte{0x0A})
  mac.Write(rawBody)
  return hex.EncodeToString(mac.Sum(nil))
}

func signVerifyResponse(timestamp, nonce, challenge string) string {
  mac := hmac.New(sha256.New, signKey)
  mac.Write([]byte(timestamp))
  mac.Write([]byte{0x0A})
  mac.Write([]byte(nonce))
  mac.Write([]byte{0x0A})
  mac.Write([]byte(challenge))
  return hex.EncodeToString(mac.Sum(nil))
}

func validHexSignature(received, expected string) bool {
  receivedBytes, err1 := hex.DecodeString(received)
  expectedBytes, err2 := hex.DecodeString(expected)
  return err1 == nil && err2 == nil &&
    len(receivedBytes) == sha256.Size &&
    hmac.Equal(receivedBytes, expectedBytes)
}

func randomNonce() (string, error) {
  value := make([]byte, 16)
  if _, err := rand.Read(value); err != nil {
    return "", err
  }
  return hex.EncodeToString(value), nil
}

func saveMessageIdempotently(event webhookEvent) error {
  // TODO: 使用 event.EventID 唯一键,在一个数据库事务中保存消息。
  return errors.New("请先实现持久化、授权校验和幂等处理")
}

func processTemplateCardAction(event webhookEvent) error {
  // TODO: 校验 TaskID、OperatorID 和 ActionKey,再按当前业务状态执行或覆盖。
  // HTTP 重试复用 EventID;用户重复点击会产生新的 EventID。
  return errors.New("请先实现持久化、授权校验和幂等处理")
}

func scheduleDirectorySyncIdempotently(event webhookEvent) error {
  // TODO: 使用 event.EventID 唯一键记录任务,异步调用 organization/sync-state。
  return errors.New("请先实现持久化、授权校验和幂等处理")
}

func scheduleGroupSyncIdempotently(event webhookEvent) error {
  // TODO: 使用 event.EventID 唯一键记录任务,异步调用 groups/changes。
  return errors.New("请先实现持久化、授权校验和幂等处理")
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
  rawBody, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64<<10))
  if err != nil {
    http.Error(w, "invalid body", http.StatusBadRequest)
    return
  }
  timestamp := r.Header.Get("X-Matrix-Timestamp")
  nonce := r.Header.Get("X-Matrix-Nonce")
  timestampMS, timeErr := strconv.ParseInt(timestamp, 10, 64)
  nowMS := time.Now().UnixMilli()
  if timeErr != nil || len(timestamp) != 13 || nonce == "" ||
    timestampMS < nowMS-300000 || timestampMS > nowMS+300000 ||
    r.Header.Get("X-Matrix-Signature-Method") != "HMAC-SHA256" {
    http.Error(w, "invalid signing headers", http.StatusUnauthorized)
    return
  }
  expected := signRequest(timestamp, nonce, rawBody)
  if !validHexSignature(r.Header.Get("X-Matrix-Signature"), expected) {
    http.Error(w, "invalid signature", http.StatusUnauthorized)
    return
  }

  var event webhookEvent
  if json.Unmarshal(rawBody, &event) != nil || event.EventID == "" ||
    r.Header.Get("X-Matrix-Event-ID") != event.EventID ||
    event.AppKey != expectedAppKey {
    http.Error(w, "invalid event", http.StatusBadRequest)
    return
  }

  if event.EventType == "webhook.url.verify" {
    responseTimestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
    responseNonce, err := randomNonce()
    if err != nil {
      http.Error(w, "nonce failed", http.StatusInternalServerError)
      return
    }
    response := map[string]any{
      "code":      0,
      "challenge": event.Message.Payload.Challenge,
      "timestamp": responseTimestamp,
      "nonce":     responseNonce,
      "signature": signVerifyResponse(
        responseTimestamp, responseNonce, event.Message.Payload.Challenge,
      ),
    }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(response)
    return
  }

  switch event.EventType {
  case "message.created":
    if err := saveMessageIdempotently(event); err != nil {
      http.Error(w, "persist failed", http.StatusInternalServerError)
      return
    }
  case "message.template_card.action":
    if err := processTemplateCardAction(event); err != nil {
      http.Error(w, "action failed", http.StatusInternalServerError)
      return
    }
  case "organization.department.changed", "organization.member.changed":
    if err := scheduleDirectorySyncIdempotently(event); err != nil {
      http.Error(w, "persist failed", http.StatusInternalServerError)
      return
    }
  case "group.app.added", "group.app.removed", "group.info.changed", "group.member.changed":
    if err := scheduleGroupSyncIdempotently(event); err != nil {
      http.Error(w, "persist failed", http.StatusInternalServerError)
      return
    }
  default:
    w.WriteHeader(http.StatusNoContent)
    return
  }
  w.WriteHeader(http.StatusNoContent)
}

func main() {
  if len(signKey) == 0 || expectedAppKey == "" { log.Fatal("请配置应用凭据") }
  http.HandleFunc("/matrix/webhook", webhookHandler)
  log.Fatal(http.ListenAndServe(":3000", nil))
}

10.6 固定签名测试向量

接入方应把下面两组向量加入自己的单元测试。输入均按 UTF-8 处理,字符串之间只有一个 LF,没有额外空格和结尾换行。

项目 固定值
sign_key 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
请求 timestamp 1787798400123
请求 nonce 00112233445566778899aabbccddeeff
原始 body {"event_id":"evt_test_001","event_type":"message.created","version":1}
期望请求签名(测试专用) 5a67a56de02dc3ad8caae65d8ff84a330fd813a583f590e49d16266b4062501b
响应 timestamp 1787798400456
响应 nonce ffeeddccbbaa99887766554433221100
challenge 9a24d55b4dc24c5ebf0d5944ce4cf738
期望响应签名 0adf85b47a96f260e64df67611b377dbb5bb17c75bd7ca6049566f9d36300fd9

10.7 普通消息事件

普通消息统一使用 event_type=message.created。事件中的应用身份固定使用开发者创建应用时取得的 app_key。当前 version 1 结构只返回顶层 occurred_at 作为消息发生时间,不返回 created_atmessage.sent_at

{
  "event_id": "evt_xxx",
  "event_type": "message.created",
  "version": 1,
  "app_key": "svc_app_xxx",
  "occurred_at": 1787798400000,
  "message": {
    "message_id": "message_xxx",
    "seq_id": 1024,
    "sender_id": "20001",
    "type": "text",
    "payload": { "text": "你好" }
  }
}
字段 JSON 类型 必填 含义、范围与单位
event_id string 本次事件的稳定唯一标识。同一事件重试时保持不变;应用必须以它作为幂等键。
event_type string 事件类型;普通用户消息固定为 message.created
version integer Webhook 事件结构版本,当前固定为 1;它不是消息版本号。
app_key string 接收事件的应用标识,与应用获取 Token 时使用的 app_key 相同。可用于一个 URL 接收多个应用事件时分流;不是密钥。
occurred_at integer 用户消息发生时间的 Unix 时间戳,单位毫秒。
message object 用户消息对象,字段定义见下方。
message.message_id string 平台消息的稳定唯一标识;按不透明字符串保存,不要解析其格式。
message.seq_id integer 该应用消息流内的递增序号,无单位。它不是全局序号;并发投递、不推送的消息类型或事件丢失会造成乱序或缺口。
message.sender_id string 发送用户的平台 ID,当前为十进制字符串。必须按字符串存储和比较,不能按 JSON 数字解析。
message.type string 消息类型,当前可能为 text/image/file/voice/video
message.payload object message.type 变化的载荷,字段定义见下方。
message.type message.payload 精确定义 不会返回
text text:文本内容 其他字段
image 原图的 file_namesize,以及可用时的 resource_id/download_url/expires_at 缩略图、尺寸、宽高
file file_namesize,以及可用时的下载字段 其他文件标识
voice file_namesizeduration_ms,以及可用时的下载字段 波形、转写等其他信息
video file_namesizeduration_ms,以及可用时的下载字段 首帧、尺寸、宽高
payload 字段 JSON 类型 适用类型 含义与单位
text string text UTF-8 文本内容。
file_name string image/file/voice/video 用户发送文件的原始文件名。
size integer image/file/voice/video 文件大小,单位字节(byte)。
duration_ms integer voice/video 媒体时长,单位毫秒。
resource_id string 媒体,可选 短期、不透明的下载资源票据;不得解析或替换成其他文件标识。
download_url string 媒体,可选 媒体下载 HTTPS URL;请求时仍须携带当前应用的 app access token。
expires_at integer 媒体,可选 resource_id/download_url 的失效时间,Unix 毫秒时间戳。

resource_iddownload_urlexpires_at 会作为一组出现;平台无法生成下载票据时三者都会省略,应用仍可保存文件名、大小和时长等元数据。

// image:只描述原图
{"file_name":"photo.jpg","size":123456,"resource_id":"msg_media_xxx","download_url":"https://openapi.example.com/openapi/v1/messages/media/download?resource_id=msg_media_xxx","expires_at":1787798700000}

// file
{"file_name":"report.pdf","size":456789,"resource_id":"msg_media_xxx","download_url":"https://openapi.example.com/openapi/v1/messages/media/download?resource_id=msg_media_xxx","expires_at":1787798700000}

// voice
{"file_name":"voice.m4a","size":34567,"duration_ms":8200,"resource_id":"msg_media_xxx","download_url":"https://openapi.example.com/openapi/v1/messages/media/download?resource_id=msg_media_xxx","expires_at":1787798700000}

// video:不返回首帧
{"file_name":"video.mp4","size":3456789,"duration_ms":15300,"resource_id":"msg_media_xxx","download_url":"https://openapi.example.com/openapi/v1/messages/media/download?resource_id=msg_media_xxx","expires_at":1787798700000}

第一版只推送上述五种类型,其他消息类型不会产生应用 Webhook。应用不得把“未收到 Webhook”等同于“用户未发送消息”。

10.8 组织目录变更事件

组织事件是“缓存失效提示”,不是字段级变更数据。只有应用已发布、拥有 contact:organization:read、Webhook 为 verified,且投递目标仍在当前发布范围内时,平台才会尝试投递。它们使用与消息事件完全相同的请求头、原始 body 签名和 2xx 成功规则。

{
  "event_id": "evt_department_xxx",
  "event_type": "organization.department.changed",
  "version": 1,
  "app_key": "svc_app_xxx",
  "occurred_at": 1788537600000,
  "department": { "id": "20001" }
}
{
  "event_id": "evt_member_xxx",
  "event_type": "organization.member.changed",
  "version": 1,
  "app_key": "svc_app_xxx",
  "occurred_at": 1788537600000,
  "member": { "user_id": "10001" }
}
事件类型 目标字段 准确含义
organization.department.changed department.id 为字符串 ID 该部门详情、该部门完整子树或直接成员列表可能失效。
organization.department.changed department.id=null 部门目录维度整体失效;必须按完整目录变化处理。
organization.member.changed member.user_id 为字符串 ID 该用户的组织人员详情可能失效。
organization.member.changed member.user_id=null 人员目录维度整体失效;必须按完整人员目录变化处理。

事件类型按“哪一类对外读取结果失效”确定。这里的“人员加入/离开”如果是指人员加入、离开或迁移部门,属于部门直接成员集合变化,发送 organization.department.changed,不是 organization.member.changed

管理操作 发送的事件 目标与处理方式
对外可见部门的新增、更新、移动、启停或删除 organization.department.changed 重新读取目标部门及相关目录数据;新建一个始终不可见的停用部门不会产生对外通知。
已有人员加入部门、离开部门,或归属在 active/inactive 间切换 organization.department.changed 加入时目标为新部门;离开时目标为原部门。
人员从部门 A 迁移至部门 B,或用一组归属替换原归属 organization.department.changed A、B 等所有受影响部门都可能分别收到通知;重新读取这些部门的直接成员列表。
部门负责人变化 organization.department.changed 部门详情中的负责人数据可能失效。
人员姓名、工号、公开联系方式、账号绑定等人员详情变化 organization.member.changed 重新读取该用户的人员详情。
任职类型、任职标签、关键联系人等人员详情内的归属属性变化 organization.member.changed 重新读取该用户的人员详情。
新建人员并在同一操作中加入部门 两种事件都可能发送 department 表示部门成员集合失效,member 表示新人员详情出现;两种事件必须分别幂等处理。
批量导入 对应维度的事件,目标 ID 可能为 null 按该维度整体失效处理,不得把 null 当作无目标而忽略。

因此,应用不能把 organization.member.changed 理解成“部门成员发生变化”。它只表示人员详情维度失效;部门成员的加入、离开和迁移以 organization.department.changed 为准。一次操作可能影响多个部门,也可能同时改变部门和人员两个维度,所以可能收到多条不同事件。

两类事件的公共字段与普通消息一致:event_id 是稳定幂等键,version 当前为 1,app_key 标识接收应用,occurred_at 是目录变化发生时间(Unix 毫秒)。事件不包含动作、旧值、新值、父链或成员归属;一次变更影响多个对象时可能产生多个各自带独立 event_id 的通知。

收到组织事件
  → 验签、检查 app_key/event_id/version
  → 以 event_id 幂等写入“待同步”任务
  → 立即返回 2xx
  → 异步携带上次已完成 Token 调用 organization/sync-state
  → need_sync=true 时按第 13 章完成带 Token 的可靠同步闭环

通知不能替代 sync-state通知投递时仍受最新发布范围限制。目标已经不在应用当前发布范围内时,指定对象通知可能被过滤;服务异常或重试耗尽也可能丢失通知。目录同步状态仍会变化,应用下次调用 sync-state 可以发现差异。因此不能以“没有收到 Webhook”证明目录未变化,也不能只按通知 ID 做最终一致性同步。

同一事件可能重复或乱序,应用应重新读取当前状态,不能把事件当作差量顺序日志。未知 event_type 应记录摘要并返回 2xx;已知组织事件但不支持其 version 时也返回 2xx,并安排一次完整组织同步。

10.9 模板卡片操作菜单事件

用户在兼容客户端点击服务号通知卡片的菜单后,应用可收到 message.template_card.action。事件标识点击用户、原消息、任务和动作。该能力适用于 user/org_node/all 目标的原始服务号通知;当前不支持群目标消息、用户代发消息或转发副本的菜单回调。

{
  "event_id": "event_action_xxx",
  "event_type": "message.template_card.action",
  "version": 1,
  "app_key": "svc_app_xxx",
  "occurred_at": 1788771665123,
  "action": {
    "message_id": "message_xxx",
    "operator_id": "20001",
    "task_id": "gift_2026_user_20001",
    "card_type": "textNotice",
    "action_key": "confirm"
  }
}
字段 JSON 类型 含义与可信边界
event_id string 本次已接受点击的事件标识。同一 HTTP 投递任务重试时不变;用户再次点击或客户端重新提交会生成新的事件标识。
event_type string 固定为 message.template_card.action
version integer 当前固定为 1
app_key string 接收事件的应用公开标识;不是密钥。
occurred_at integer 平台接受本次点击的 Unix 毫秒时间戳。
action.message_id string 该接收者实际看到的服务号消息 ID,不一定等于批量发送响应中的 data.message_id。按不透明字符串处理。
action.operator_id string 点击时平台登录态中的真实用户 ID。它能回答“谁点击了”,但不代表应用业务上已经授权该用户执行动作。
action.task_id string 发送卡片时由应用提供,并与本次发送操作绑定的任务标识;平台已完成一致性比对。
action.card_type string 原发送任务的卡片类型,当前为 textNoticenewsNotice
action.action_key string 客户端提交的菜单动作键。平台只校验非空、长度和字符集,不解析原消息正文核对它是否确实存在于菜单中。

应用必须再次做业务校验验证签名和 app_key/version 后,应用应检查 task_id 是否属于当前应用业务、operator_id 是否有权操作该任务、action_key 是否在任务允许的动作集合中,以及任务是否已过期或结束。校验失败时可以记录摘要并返回 2xx,不执行对应业务。

  • 平台能可靠标识当前登录的点击人;当前不会额外查询该点击人是否属于原始 user/org_node/all 接收范围。应用必须独立校验点击用户对当前业务任务的操作权限。
  • 普通转发会生成新的 P2P 或群消息 ID,不再是原始服务号应用下行消息。即使客户端仍显示操作菜单,使用转发副本的消息 ID 点击也会被拒绝,不产生本事件。
  • 同一动作可能多次送达,用户也可以重复点击。每个通过校验的请求都是独立事件,可能因网络重试或用户操作而表达相同业务意图;应用可以根据当前业务状态让后到事件覆盖先到事件,或自行忽略不再适用的动作。
  • 卡片中的 taskId 当前没有平台过期时长;应用应自行维护任务有效期。不要依赖卡片自动阻止过期任务的操作。
  • 客户端收到“已接受”只表示平台已经接受并尝试派发事件,不表示应用 Webhook 已经收到或处理成功。

10.10 群应用变化事件

群 Webhook 是变化加速通知,不是授权事实或可靠消息队列。应用即使不配置 Webhook,也可以通过第 14 章的 groups/changes 最终发现群关系、群信息和成员变化。群事件与本章其他事件使用完全相同的签名、重试、幂等和 2xx 成功规则。

{
  "event_id": "event_group_xxx",
  "event_type": "group.member.changed",
  "version": 1,
  "app_key": "svc_app_xxx",
  "occurred_at": 1789027200123,
  "group": {
    "group_id": "20001",
    "relation_version": 3,
    "group_updated_at": 1789027000000,
    "member_updated_at": 1789027200123
  }
}
事件类型 含义 推荐处理
group.app.added 应用被群主或管理员添加到群,形成一次新的 active 授权。 调用 groups/changes,为该群重新获取基本信息并从无 cursor 开始全量同步成员。
group.app.removed 群主/管理员移除应用,或群解散等群侧操作使关系失效。 立即停止该群任务;以 groups/changes.removed_group_ids 收敛并删除不再需要的本地群数据。
group.info.changed 群名称等当前对外群基本信息发生变化。 调用 groups/changes 后重新读取 groups/info
group.member.changed 成员增加、移除、显示名称或 owner/admin/member 角色发生变化。 调用 groups/changes 后使用该群成员 cursor 增量读取;服务端要求全量时丢弃旧 cursor。
字段 类型 说明
event_id string 稳定幂等键;同一次 HTTP 投递任务重试时不变。
event_type string 只能是上表四种已知群事件之一。
version integer 当前固定为 1。
app_key string 接收事件的应用公开标识。
occurred_at integer 事件发生时间,Unix 毫秒。
group.group_id string 目标群 ID;按字符串保存和传递。
group.relation_version integer 该应用与群的关系版本;变化表示一次新的添加或移除关系。
group.group_updated_at integer 群基本信息最后变化时间,Unix 毫秒。
group.member_updated_at integer 群成员基本信息最后变化时间,Unix 毫秒。
  • 事件不携带群名称、成员列表、变化前后值或操作人,不能直接拿事件覆盖本地业务数据。
  • Webhook 可能重复、乱序或最终投递失败;先按 event_id 幂等记录,尽快返回 2xx,再异步调用 groups/changes
  • group.app.removed 到达时读取接口权限可能已经失效,这是正常行为;不要依赖再次调用 groups/info 判断是否移除。
  • 未知事件或未知版本应记录摘要并返回 2xx,然后安排一次 groups/changes 同步,避免无意义重试。

10.11 下载图片、文件、语音和视频

download_url 是短期地址,通常 300 秒过期;应用应在事件处理阶段尽快下载。请求必须携带当前应用的 app access token,资源同时绑定应用、消息、发送者和文件,不能跨应用使用。

curl -L "${DOWNLOAD_URL}" \
  -H "Authorization: Bearer ${APP_ACCESS_TOKEN}" \
  --output received-media.bin
位置 字段 类型/格式 必填 说明
Header Authorization Bearer <app_access_token> 必须是接收该事件应用的有效 App access token。
Query resource_id string 直接使用 Webhook 返回的短期不透明票据;使用完整 download_url 时已包含。
Header Range HTTP byte range 当前不支持;携带时不会获得分片或断点续传语义。
响应 Header Content-Type MIME type 成功时 平台保存的媒体 MIME 类型;应用仍应把内容视为不可信输入。
响应 body 媒体二进制 bytes 成功时 原始图片、文件、语音或视频内容,不是 JSON。
  • 不要解析或修改 resource_id;只将原值用于本接口。
  • 下载接口不支持 Range,不要做分片或断点续传;单应用与 IP 当前限制为每分钟 60 次。
  • HTTP 410 / 业务码 170612 表示票据过期;当前没有重新签发旧消息下载地址的接口。
  • HTTP 403 / 170613 表示资源不属于当前 Token 对应应用;HTTP 404 / 170611 表示消息或文件已不存在或校验不一致。
  • Webhook 中下载字段缺失时,只能保存元数据,不能使用未文档化的文件标识猜测下载地址。

下载错误补充:HTTP 400 / 170610 表示 query 参数不合法;HTTP 416 / 170610 表示提交了 Range。HTTP 410 / 170612 也可能表示票据无效或被修改;原消息校验服务异常可返回 HTTP 500 / 170611。请同时判断 HTTP 与 code,不能只按 code 判断资源已删除。鉴权失败和全局/专用限流仍可能返回 HTTP 200 业务错误。

10.12 成功、重试、幂等和停推

规则 平台行为 应用必须怎么做
单次超时 每次 HTTP 请求最多 5 秒 先完成验签和本地持久化,再异步执行业务;不要在请求内调用慢服务
成功条件 普通消息、模板卡片操作、组织事件和群事件收到任意 2xx 即成功,响应 body 被忽略 消息、卡片操作或同步任务完成必要处理后返回 204 或 200;失败返回非 2xx
即时重试 首次失败后立即再试 3 次,共最多 4 次;没有退避 event_id 幂等,不能按 request_id 去重
连续失败 一个事件 4 次均失败才计 1 次;当天连续 5 个最终失败则停推 监控自身非 2xx 和超时,修复后联系管理员重新验证
当天累计失败 当天最终失败累计 20 个事件则停推;成功只清零连续计数,不清零当天累计数 不要依赖偶发成功抵消当日失败总量
恢复 状态写为 failed 后不再推送;管理员主动验证成功后恢复并清除计数 更新服务、证书路由或 sign key 后完成验证响应

投递语义Webhook 不保证永久可靠投递。用户消息和模板卡片操作当前都没有应用主动拉取或重放接口,不能把 Webhook 当作永久存档;卡片操作可能因服务异常或重试耗尽而丢失。组织事件丢失时使用 organization/sync-state 恢复,群事件丢失时使用 groups/changes 恢复最终一致性。

10.13 sign_key 轮换和安全清单

  • sign_key 只保存在应用后端密钥系统,不进入 H5、客户端、普通日志、监控标签或错误响应。
  • 管理员重置 sign_key 后旧密钥立即失效。应用后端更新新密钥后,必须让管理员主动重新验证,并清空旧群同步游标重新全量同步。
  • 所有事件先验签再解析和处理。未知 event_type/version/message.type 应记录摘要并返回 2xx,避免无意义重试;未知组织事件版本触发完整目录同步,未知群事件版本触发一次 groups/changes 同步。
  • 使用唯一键保存 event_id。同一事件重复到达时直接返回 2xx,不重复产生回复、通知或计费。
  • 日志可以记录 event_id/request_id/app_key/message.type,不得记录签名密钥、完整签名、下载票据或敏感消息正文。