事件订阅
事件订阅用于接收平台推送的业务事件:平台向应用配置的 HTTPS 地址发送回调,应用侧负责验签、解密、幂等和快速响应。当前已开放通讯录成员、部门和成员部门关系变化事件,后续可扩展其他业务事件。
事件订阅是出站能力:TWT Link 是发送方,应用的服务端是接收方。应用不需要轮询, 只需提供一个可被企业 TWT Link 私有化实例访问的 HTTP 地址。
当前事件范围
目前支持用户、部门、成员与部门关系的新增、变更与删除事件通知。
推送方式固定为 HTTP 推送:应用在管理后台配置一个请求网址,TWT Link 在事件发生时 以加密 HTTP POST 将事件投递过去。
投递的是事件本身,不是差量快照:同一次业务操作可能产生多条事件(例如新增成员会同时
产生 contact.user.created 与 contact.membership.changed),应用需要按事件逐条处理。
前置条件
- 应用为内部应用,且已创建。
- 在管理后台「内部应用 → 事件订阅 → 订阅管理」中完成配置:
加密
aes_key、签名token、请求网址,三者必须同时填写。 - 打开需要接收的事件开关,开关默认全部关闭。
- 发布应用版本。
- 应用处于启用状态。
推送只使用已发布版本上的配置(请求网址、
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_key | 43 位大小写字母或数字,即 32 字节 AES 密钥的 base64 编码(去掉 = 填充,解码时补回)。管理后台可点击重置按钮生成 |
签名 token | 3–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=...×tamp=...&nonce=...
Content-Type: application/json
Accept: application/json
{"encrypt":"..."}
| 项目 | 值 |
|---|---|
| 方法 | POST |
| 协议字段位置 | 查询串 signature、timestamp、nonce |
| 请求体 | 仅一个 encrypt 字段的 JSON 对象 |
| 超时 | 2500ms,超时即视为投递失败 |
| 跳转 | 不跟随 3xx 跳转,3xx 直接视为失败 |
| 协议字段取值 | timestamp 为毫秒级 Unix 时间戳字符串;nonce 为 16 位随机字母数字串 |
请求体中的明文经过加密后不会直接出现,签名和明文分离:签名在 URL 上,密文在请求体中。
加解密与签名
事件推送采用钉钉兼容的加密回调协议,算法固定,接收端需要自行实现解密与验签。
密钥与凭据
| 凭据 | 用途 |
|---|---|
aes_key | 43 位文本。解码方式:先补一个 = 变成长度 44 的字符串,再做标准 base64 解码,得到 32 字节 AES 密钥 |
token | 参与签名计算 |
| 应用 AppId | 即加密帧中的 owner_key,用于确认事件确实是发给本应用的 |
解密步骤
-
取 URL 上的
signature、timestamp、nonce与请求体中的encrypt,按 6.3 校验签名。 -
对
encrypt做 base64 解码,得到密文。 -
用 32 字节 AES 密钥做 AES-256-CBC 解密,IV 取密钥的前 16 字节。
-
去除 PKCS#7 填充。注意填充块大小是 32 字节,不是 AES 的 16 字节, 这是该协议特有的约定,按 16 字节去填充会失败。
-
按下面的帧结构切分明文:
┌────────────────┬──────────────────┬───────────────┬───────────────┐│ 16 字节随机前缀 │ 4 字节明文长度 │ 事件明文 │ owner_key ││ │ (大端无符号整数)│(JSON,UTF-8)│(应用 AppId)│└────────────────┴──────────────────┴───────────────┴───────────────┘ -
用第 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"
}
}
| 字段 | 类型 | 说明 |
|---|---|---|
EventType | string | 事件码,取值见第 3 章 |
EventTime | int64 | 事件发生时间,毫秒级 Unix 时间戳 |
eventId | string | 事件唯一 ID,幂等依据,见第 9 章 |
BizId | string | 与 eventId 相同,兼容协议字段 |
TimeStamp | int64 | 与 EventTime 相同,兼容协议字段 |
entityType | string | 实体类型,user 或 department |
entityId | string | 实体 ID,字符串形式的用户 ID 或部门 ID |
Data | object | 事件业务数据,结构随 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 的字段全集如下,并不是所有字段都会出现:
| 字段 | 类型 | 说明 |
|---|---|---|
userId | uint64 | 用户 ID,与 entityId 一致,始终下发 |
name | string | 姓名 |
avatar | string | 头像地址 |
position | string | 职位 |
employeeNo | string | 工号 |
status | uint8 | 账号状态:0 正常,1 禁用 |
departmentIds | uint64[] | 当前所属部门 ID 列表,已排序 |
previousDepartmentIds | uint64[] | 变更前的部门 ID 列表,仅关系变更类事件下发 |
previousStatus | uint8 | 变更前的账号状态,仅状态变更类事件下发 |
action | string | 业务动作,见下表 |
deleted | bool | 为 true 表示该用户已被删除 |
因此接收端必须按「字段可能缺失」来解析:例如 contact.user.updated 既可能是完整资料
变更,也可能只是头像变更,后者不含 name、position、employeeNo。需要完整资料时
请调用获取成员详情接口补齐,不要用事件字段覆盖本地全量数据。
action 取值:
action | 出现的事件 | 含义 |
|---|---|---|
create | contact.user.created | 单个创建成员,或注册审核通过后创建 |
import | contact.user.created | 批量导入创建 |
update | contact.user.updated、contact.user.status.changed、contact.membership.changed | 常规更新 |
status | contact.user.status.changed | 通过启用/禁用操作变更状态 |
delete | contact.user.status.changed、contact.membership.changed | 删除成员 |
add | contact.membership.changed | 成员加入部门 |
注意:仅状态变更的事件(action=status)departmentIds 为空数组,
不要把它理解为「该用户已不属于任何部门」,需要部门信息时请以 contact.user.updated
或 contact.membership.changed 为准。
部门事件 Data
EventType 以 contact.department. 开头时:
| 字段 | 类型 | 说明 |
|---|---|---|
departmentId | uint64 | 部门 ID,与 entityId 一致 |
name | string | 部门名称 |
parentId | uint64 | 父部门 ID,根部门的父部门 ID 为 0 |
sort | uint32 | 排序值 |
contactVisible | uint8 | 可见性:0 全员可见,1 仅本部门及下级部门可见 |
previousName | string | 变更前的部门名称,仅更新事件下发 |
previousParentId | uint64 | 变更前的父部门 ID,仅更新事件下发 |
previousSort | uint32 | 变更前的排序值,仅更新事件下发 |
previousContactVisible | uint8 | 变更前的可见性,仅更新事件下发 |
deleted | bool | 为 true 表示该部门已被删除 |
成员部门关系事件 Data
EventType 为 contact.membership.changed 时,entityType 为 user,entityId 为用户 ID:
| 字段 | 类型 | 说明 |
|---|---|---|
userId | uint64 | 用户 ID |
departmentIds | uint64[] | 变更后的所属部门 ID 列表 |
previousDepartmentIds | uint64[] | 变更前的所属部门 ID 列表 |
status | uint8 | 账号状态 |
action | string | add(加入)、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 由接收端自己生成,不需要回传请求里的值;
签名用接收端生成的值重新计算即可。
满足以下全部条件才算投递成功:
- HTTP 状态码为 2xx;
- 响应体是合法 JSON 且包含
msg_signature、encrypt、timeStamp、nonce四个字段; - 签名校验通过;
- 解密后的明文恰好是
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 的统一响应格式;完整资料变更仍应通过获取部门列表查询。