跳到主要内容

事件订阅

事件订阅用于接收平台推送的业务事件:平台向应用配置的 HTTPS 地址发送回调,应用侧负责验签、解密、幂等和快速响应。当前已开放通讯录成员、部门和成员部门关系变化事件,后续可扩展其他业务事件。

事件订阅是出站能力:TWT Link 是发送方,应用的服务端是接收方。应用不需要轮询, 只需提供一个可被企业 TWT Link 私有化实例访问的 HTTP 地址。

当前事件范围​

目前支持用户、部门、成员与部门关系的新增、变更与删除事件通知。

推送方式固定为 HTTP 推送:应用在管理后台配置一个请求网址,TWT Link 在事件发生时 以加密 HTTP POST 将事件投递过去。

投递的是事件本身,不是差量快照:同一次业务操作可能产生多条事件(例如新增成员会同时 产生 contact.user.created 与 contact.membership.changed),应用需要按事件逐条处理。

前置条件​

  1. 应用为内部应用,且已创建。
  2. 在管理后台「内部应用 → 事件订阅 → 订阅管理」中完成配置: 加密 aes_key、签名 token、请求网址,三者必须同时填写。
  3. 打开需要接收的事件开关,开关默认全部关闭。
  4. 发布应用版本。
  5. 应用处于启用状态。

推送只使用已发布版本上的配置(请求网址、aes_key、token、各事件开关)。 保存配置或调整开关后,必须发布新版本才会生效;轮换凭证同样需要重新发布。 未发布、已停用或已删除的应用不会收到任何推送,配置保存成功也不代表已经开始推送。

订阅的事件​

事件码(EventType)事件名称实体类型触发时机
contact.user.created用户新增user管理员创建成员,或注册申请审核通过后创建成员
contact.user.updated用户信息变更user成员资料(姓名、职位、工号、所属部门等)变更;成员自行修改头像
contact.user.status.changed用户状态变更user成员被启用或禁用;成员被删除
contact.department.created部门新增department创建部门,含企业根部门首次创建
contact.department.updated部门信息变更department部门名称、父部门、排序或可见性变更
contact.department.deleted部门删除department删除部门,含批量删除
contact.membership.changed成员部门关系变更user成员加入或移出部门、批量导入成员

两条需要特别注意的语义:

  • 没有独立的用户删除事件。 成员被删除时下发 contact.user.status.changed, 其 Data.deleted 为 true。
  • 部门删除同样通过 Data.deleted 标记,事件码仍是 contact.department.deleted。

配置项​

配置项说明
推送方式固定为 HTTP推送,不可选择
加密 aes_key43 位大小写字母或数字,即 32 字节 AES 密钥的 base64 编码(去掉 = 填充,解码时补回)。管理后台可点击重置按钮生成
签名 token3–32 位大小写字母或数字
请求网址接收事件的 HTTP/HTTPS 地址,规则见 4.1

aes_key 与 token 由应用自行决定,平台只校验格式。页面上的「重置」只生成新的随机值, 不会自动保存,需点击保存并发布版本后才生效。凭证在页面上以脱敏形式展示。

请求网址规则​

  • 必须是 http:// 或 https:// 开头的绝对地址,且带主机名;其他协议一律拒绝。
  • 不接受带用户信息的地址(如 http://user:pass@host/path)。
  • 私有化部署下,TWT Link 实例需要能够访问该地址;同一企业网络内可访问的内网地址同样可用, 不要求公网域名。
  • 保存时只做地址语法校验,不会主动探测该地址。连通性以第一次真实事件投递为准, 请自行确认地址可达、证书有效(若为 HTTPS)。
  • 请求网址不要自带 signature、timestamp、nonce 查询参数(含兼容拼写 msg_signature、 timeStamp):投递时会按本次投递重新填充,自带的值会被覆盖。

推送请求​

事件发生时,TWT Link 向应用配置的请求网址发起一次 HTTP POST:

POST /your/callback?signature=...&timestamp=...&nonce=...
Content-Type: application/json
Accept: application/json

{"encrypt":"..."}
项目值
方法POST
协议字段位置查询串 signature、timestamp、nonce
请求体仅一个 encrypt 字段的 JSON 对象
超时2500ms,超时即视为投递失败
跳转不跟随 3xx 跳转,3xx 直接视为失败
协议字段取值timestamp 为毫秒级 Unix 时间戳字符串;nonce 为 16 位随机字母数字串

请求体中的明文经过加密后不会直接出现,签名和明文分离:签名在 URL 上,密文在请求体中。

加解密与签名​

事件推送采用钉钉兼容的加密回调协议,算法固定,接收端需要自行实现解密与验签。

密钥与凭据​

凭据用途
aes_key43 位文本。解码方式:先补一个 = 变成长度 44 的字符串,再做标准 base64 解码,得到 32 字节 AES 密钥
token参与签名计算
应用 AppId即加密帧中的 owner_key,用于确认事件确实是发给本应用的

解密步骤​

  1. 取 URL 上的 signature、timestamp、nonce 与请求体中的 encrypt,按 6.3 校验签名。

  2. 对 encrypt 做 base64 解码,得到密文。

  3. 用 32 字节 AES 密钥做 AES-256-CBC 解密,IV 取密钥的前 16 字节。

  4. 去除 PKCS#7 填充。注意填充块大小是 32 字节,不是 AES 的 16 字节, 这是该协议特有的约定,按 16 字节去填充会失败。

  5. 按下面的帧结构切分明文:

    ┌────────────────┬──────────────────┬───────────────┬───────────────┐
    │ 16 字节随机前缀 │ 4 字节明文长度 │ 事件明文 │ owner_key │
    │ │ (大端无符号整数)│(JSON,UTF-8)│(应用 AppId)│
    └────────────────┴──────────────────┴───────────────┴───────────────┘
  6. 用第 2 段声明的长度截取事件明文,剩余字节即 owner_key; 若它与本应用 AppId 不一致,应丢弃该消息。

签名算法​

签名与 token、timestamp、nonce、encrypt 四个值相关,算法如下:

1. 将 token、timestamp、nonce、encrypt 四个字符串按字典序(字符串比较)排序
2. 排序后按顺序直接拼接(无分隔符)
3. 对拼接结果做 SHA-1 摘要
4. 取小写十六进制字符串作为签名

将计算结果与 URL 上的 signature 比较,不一致则拒绝该请求。 encrypt 参与签名的是 base64 文本本身,不是解码后的字节。

参考实现(解密与验签)​

以下为 Golang 参考实现,可直接对照编写其他语言的版本。

package callback

import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/sha1"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"errors"
"sort"
)

// decodeAESKey 将 43 位 aes_key 解码为 32 字节密钥。
func decodeAESKey(aesKey string) ([]byte, error) {
return base64.StdEncoding.DecodeString(aesKey + "=")
}

// sign 计算协议签名:四个值字典序排序后拼接,取 SHA-1 小写十六进制。
func sign(token, timestamp, nonce, encrypted string) string {
values := []string{token, timestamp, nonce, encrypted}
sort.Strings(values)
sum := sha1.Sum([]byte(values[0] + values[1] + values[2] + values[3]))
return hex.EncodeToString(sum[:])
}

// Decrypt 校验签名并解出事件明文,返回明文与帧内携带的 owner_key。
func Decrypt(token, aesKey, signature, timestamp, nonce, encrypted string) ([]byte, string, error) {
if sign(token, timestamp, nonce, encrypted) != signature {
return nil, "", errors.New("signature mismatch")
}
key, err := decodeAESKey(aesKey)
if err != nil {
return nil, "", err
}
ciphertext, err := base64.StdEncoding.DecodeString(encrypted)
if err != nil || len(ciphertext)%aes.BlockSize != 0 {
return nil, "", errors.New("invalid ciphertext")
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, "", err
}
plain := make([]byte, len(ciphertext))
cipher.NewCBCDecrypter(block, key[:aes.BlockSize]).CryptBlocks(plain, ciphertext)

plain, err = unpad32(plain)
if err != nil {
return nil, "", err
}
if len(plain) < 20 {
return nil, "", errors.New("frame too short")
}
length := binary.BigEndian.Uint32(plain[16:20])
start := 20
end := start + int(length)
if end > len(plain) {
return nil, "", errors.New("payload length exceeds frame")
}
return plain[start:end], string(plain[end:]), nil
}

// unpad32 按 32 字节块做 PKCS#7 去填充。
func unpad32(data []byte) ([]byte, error) {
const blockSize = 32
if len(data) == 0 || len(data)%blockSize != 0 {
return nil, errors.New("invalid padded length")
}
padding := int(data[len(data)-1])
if padding < 1 || padding > blockSize || padding > len(data) {
return nil, errors.New("invalid padding")
}
if !bytes.Equal(data[len(data)-padding:], bytes.Repeat([]byte{byte(padding)}, padding)) {
return nil, errors.New("invalid padding")
}
return data[:len(data)-padding], nil
}

事件明文结构​

解密后得到 UTF-8 编码的 JSON 文本,结构如下:

{
"EventType": "contact.user.created",
"EventTime": 1757900000000,
"eventId": "6f1c0f4e-2a3b-4c5d-8e9f-0a1b2c3d4e5f",
"BizId": "6f1c0f4e-2a3b-4c5d-8e9f-0a1b2c3d4e5f",
"TimeStamp": 1757900000000,
"entityType": "user",
"entityId": "10001",
"Data": {
"userId": 10001,
"name": "张三",
"position": "产品经理",
"employeeNo": "E1001",
"status": 0,
"departmentIds": [2, 7],
"action": "create"
}
}
字段类型说明
EventTypestring事件码,取值见第 3 章
EventTimeint64事件发生时间,毫秒级 Unix 时间戳
eventIdstring事件唯一 ID,幂等依据,见第 9 章
BizIdstring与 eventId 相同,兼容协议字段
TimeStampint64与 EventTime 相同,兼容协议字段
entityTypestring实体类型,user 或 department
entityIdstring实体 ID,字符串形式的用户 ID 或部门 ID
Dataobject事件业务数据,结构随 EventType 变化,见 7.1–7.3

Data 的字段多数是可选的:值为空字符串、false 或空数组时不会出现在 JSON 中, 接收端需要按缺省值处理,不要依赖字段一定存在。

两个例外需要注意:

  • userId、status、departmentIds 三个键一定存在,未赋值时取值为 0。
  • departmentIds 在所属部门为空时可能是 null,解析时请把 null 与缺省都按空数组处理。

用户事件 Data​

EventType 为 contact.user.created、contact.user.updated、contact.user.status.changed 时, Data 的字段全集如下,并不是所有字段都会出现:

字段类型说明
userIduint64用户 ID,与 entityId 一致,始终下发
namestring姓名
avatarstring头像地址
positionstring职位
employeeNostring工号
statusuint8账号状态:0 正常,1 禁用
departmentIdsuint64[]当前所属部门 ID 列表,已排序
previousDepartmentIdsuint64[]变更前的部门 ID 列表,仅关系变更类事件下发
previousStatusuint8变更前的账号状态,仅状态变更类事件下发
actionstring业务动作,见下表
deletedbool为 true 表示该用户已被删除

因此接收端必须按「字段可能缺失」来解析:例如 contact.user.updated 既可能是完整资料 变更,也可能只是头像变更,后者不含 name、position、employeeNo。需要完整资料时 请调用获取成员详情接口补齐,不要用事件字段覆盖本地全量数据。

action 取值:

action出现的事件含义
createcontact.user.created单个创建成员,或注册审核通过后创建
importcontact.user.created批量导入创建
updatecontact.user.updated、contact.user.status.changed、contact.membership.changed常规更新
statuscontact.user.status.changed通过启用/禁用操作变更状态
deletecontact.user.status.changed、contact.membership.changed删除成员
addcontact.membership.changed成员加入部门

注意:仅状态变更的事件(action=status)departmentIds 为空数组, 不要把它理解为「该用户已不属于任何部门」,需要部门信息时请以 contact.user.updated 或 contact.membership.changed 为准。

部门事件 Data​

EventType 以 contact.department. 开头时:

字段类型说明
departmentIduint64部门 ID,与 entityId 一致
namestring部门名称
parentIduint64父部门 ID,根部门的父部门 ID 为 0
sortuint32排序值
contactVisibleuint8可见性:0 全员可见,1 仅本部门及下级部门可见
previousNamestring变更前的部门名称,仅更新事件下发
previousParentIduint64变更前的父部门 ID,仅更新事件下发
previousSortuint32变更前的排序值,仅更新事件下发
previousContactVisibleuint8变更前的可见性,仅更新事件下发
deletedbool为 true 表示该部门已被删除

成员部门关系事件 Data​

EventType 为 contact.membership.changed 时,entityType 为 user,entityId 为用户 ID:

字段类型说明
userIduint64用户 ID
departmentIdsuint64[]变更后的所属部门 ID 列表
previousDepartmentIdsuint64[]变更前的所属部门 ID 列表
statusuint8账号状态
actionstringadd(加入)、update(调整)、delete(随成员删除)、import(批量导入)

请以本章列出的字段为准,不要依赖未列出的键。

接收端响应​

接收端必须在 2500ms 内返回 HTTP 2xx,且响应体是加密信封:

{
"msg_signature": "6f1c0f4e...",
"encrypt": "....",
"timeStamp": "1757900000123",
"nonce": "Ab3dE9fGh1JkLm2n"
}
字段说明
encrypt将明文 success(7 个 ASCII 字符)用同一套协议加密后的 base64 文本
msg_signature对上一步的 encrypt 与本次响应的 timeStamp、nonce、token 计算出的签名,算法同 6.3
timeStamp本次响应自行生成的毫秒时间戳,字段名首字母大写 S;也接受 timestamp
nonce本次响应自行生成的随机串

响应中的 timeStamp 与 nonce 由接收端自己生成,不需要回传请求里的值; 签名用接收端生成的值重新计算即可。

满足以下全部条件才算投递成功:

  1. HTTP 状态码为 2xx;
  2. 响应体是合法 JSON 且包含 msg_signature、encrypt、timeStamp、nonce 四个字段;
  3. 签名校验通过;
  4. 解密后的明文恰好是 success。

响应体上限 64 KiB,超出即判为失败。返回明文 success(不加密)不算成功。

重试与幂等​

事件与业务写入在同一个事务中提交,随后由 TWT Link 的投递任务展开并回调。

投递失败(超时、非 2xx、响应体不符合第 8 章要求、网络错误)时,TWT Link 会自动重试, 退避从 2 秒开始逐次翻倍、上限 1 分钟,最多尝试 12 次;达到上限后事件落为失败终态, 不再投递,TWT Link 不保证事件最终一定送达。重要业务请以应用侧主动拉取通讯录接口 (见获取部门列表)作为兜底。

在重试与并发下,接收端仍可能收到重复事件,必须按 eventId 幂等:

  • 以 eventId 作为唯一键落库或缓存,重复事件直接返回成功。
  • 幂等窗口建议不短于 24 小时。
  • TWT Link 侧的去重记录保留 7 天,失败记录保留 30 天,之后不再具备平台侧去重依据, 请勿依赖平台侧记录做长期去重。
  • 同一次业务操作会产生多条不同 eventId 的事件(例如创建成员同时产生 contact.user.created 与 contact.membership.changed),它们是独立事件,不能互相去重。

关于顺序:投递链路不保证事件到达顺序,同一实体的多次变更也可能乱序或交叠到达, 重试还会让较早的事件晚于较新的事件送达。接收端应结合 EventTime 与 eventId 做状态合并, 不要按到达顺序直接覆盖本地数据。

排障​

现象排查方向
配置保存成功但收不到事件是否已发布版本;应用是否已启用;对应事件开关是否打开;触发的事件是否在一期七项范围内
完全收不到请求TWT Link 实例能否访问该地址(内网地址需同网可达);地址协议与主机名是否合法;端点是否被防火墙或鉴权拦截
签名校验不通过token 是否为当前已发布版本上的值;encrypt 参与签名的是 base64 文本;排序是否为字典序
解密失败或填充错误aes_key 是否为当前已发布版本上的值;解码前是否补了 =;去填充块大小是否为 32
解密成功但 JSON 解析失败明文是 UTF-8 JSON,需按 Data 结构解析,注意字段可能缺失
报 owner_key 不匹配帧尾的 AppId 是否与本应用一致,确认事件不是发给其他应用的
一直被重试响应是否在 2500ms 内返回;是否返回 2xx;响应体是否为加密的 success;响应体是否超过 64 KiB

关于 check_url:当前版本不会主动发起 URL 校验事件。配置保存时不会探测请求网址, 接收到 EventType 为 check_url 的请求即可忽略。

相关文档​

事件回调不是开放 API 的统一响应格式;完整资料变更仍应通过获取部门列表查询。