青鸾企业微信能力开放平台。通过稳定的 HTTP API 调用企业微信能力:实例托管、消息收发、联系人 / 群聊 / 标签管理、朋友圈、文件与回调。
对接须知
接入流程
- 注册并登录开发者控制台 https://open.qingluanbot.com。
- 在「实例与回调」一键扫码登录企业微信,获得实例标识
appid。 - 在「API Key」创建一个 Key(请求时通过请求头携带)。
- 用
API Key+appid调用业务接口。 - 如需接收消息 / 事件,配置 Webhook 回调地址(见下方「Webhook 回调服务」)。
基础约定
- 统一 API 地址:
https://api.qingluanbot.com - 请求:
POST /qingluan/api/{模块}/{动作},Content-Type: application/json - 鉴权:请求头
QL-Key: <你的 API Key>(也支持Authorization: Bearer <key>) - 实例:请求体携带
appid(所有接口必填),它决定这次调用发给哪个企业微信号。 不传返回400。请求体统一使用appid,不要同时传入多个实例标识字段。 - 响应:原样返回,不额外包装信封。
⚠️ code = 0 的确切含义
code = 0 表示请求被接受,不保证副作用一定发生。个别接口在参数缺失或有误时 仍会返回 code = 0(例如发送类接口 content 结构不对时,会在会话里留下一条空消息)。
建议:发送前自行校验必填字段;对结果敏感的操作,用对应的查询接口复核一次 (发消息用「拉取消息」,群操作用「获取群资料」,朋友圈用「查询朋友圈详情」)。
媒体文件:先上传,再发送
图片 / GIF / 语音 / 文件不能直接传 URL 或二进制,要分两步 —— 上传接口返回的 data,整体作为发送接口的 content,字段逐个对应,不需要自己拼:
| 要发什么 | 第一步 | 第二步 |
|---|---|---|
| 图片 | api/cdn/uploadImage | api/message/sendImage,content = 上一步的 data |
| GIF | api/cdn/uploadImage | api/message/sendGif,content = 上一步的 data |
| 文件 | api/cdn/uploadFile | api/message/sendFile,content = 上一步的 data |
| 语音 | api/cdn/uploadFile | api/message/sendVoice,content = 上一步的 data 加 voiceTime |
⚠️ 大整数精度(JS 客户端必读)
响应里的 roomId / conversationId / uin 是 17~19 位整数,超过 JavaScript 的 Number.MAX_SAFE_INTEGER(9007199254740991)。浏览器 JSON.parse 会静默取近似值 (例:10952820944829695 变成 10952820944829696),末位出错且不抛异常。
JS / TypeScript 客户端必须用 JSON.parse 的 reviver、或按文本正则取值,不要直接 parse 后当数字用。 这是「原样返回」的固有代价:转成字符串就不叫原样了。
调用频率限制
每个开发者账号 每分钟 300 次(按账号计,不按 Key —— 开多把 Key 不会增加额度)。
超限返回 HTTP 429,业务码 -42900,并带标准的 Retry-After 头(单位:秒):
{ "code": -42900, "message": "调用过于频繁,请 N 秒后重试。…", "data": null }正确的处理方式是读 Retry-After 并退避,不要立刻重试 —— 立刻重试只会继续消耗额度。
这个额度是按实际用量定的:现网每分钟调用量 p99 是 7 次、历史峰值 26 次, 300 已是十倍以上余量,正常业务不会碰到。触发它通常意味着你的客户端在重试循环里。 如果你的业务确实需要更高频率(批量导入、历史数据回填等),联系客服单独调整, 不必自己想办法绕。
这几个接口有额外行为
参数和返回都与其余接口一致,直接调即可;差别在于青鸾会在中间多做一步, 免得你自己再搭一遍:
| 接口 | 青鸾额外做了什么 |
|---|---|
api/device/create | 先校验你的账号额度与有效期,建成后把 appid 登记到你名下 —— 不登记的话它过不了后续接口的归属校验,等于建了个用不了的设备 |
api/long/start、api/long/updateCallbackURL | 你传的 callbackUrl 会被记为你的接收地址;报给能力层的是青鸾的接收器。事件先到青鸾,再带青鸾签名转发给你,并附带失败重投与去重。你收到的包体结构见「回调(事件推送)」 |
api/long/stop | 关闭后青鸾的定时探活不会再自动把它拉起来;下次调 api/long/start 即恢复自动维护 |
callbackUrl必须是可公网访问的 http(s) 地址,且不能填青鸾自己的接收地址。
错误码
青鸾自己产生的错误一律用负数 code,不会和能力层的业务码混淆(能力层的码见各接口说明)。 HTTP 状态与 code 一一对应,判断其一即可。
code | HTTP | 含义 | 怎么处理 |
|---|---|---|---|
401 | 401 | API Key 缺失、无效或已撤销 | 检查请求头 QL-Key |
-40001 | 400 | 缺少 appid,或请求体不是 JSON 对象 | 补上 appid;确认 Content-Type: application/json |
-40002 | 400 | 实例标识字段名不对,或同时传了多种写法且值不一致 | 只传一个 appid |
-40004 | 400 | 请求路径含义不明确(多余斜杠、点段等) | 用规范路径 /qingluan/api/{模块}/{动作} |
-40010 | 400 | 配置回调时缺少 appid | 补上 appid |
-40011 | 400 | callbackUrl 不是 http(s) 地址 | 换成 http(s) 开头的完整地址 |
-40012 | 400 | 回调地址填成了青鸾的接收地址 | 填你自己服务的公网地址 |
-40013 | 400 | 回调地址不可用:域名解析不到,或落在内网 / 环回地址 | 换一个可公网访问的地址 |
-40402 | 402/403 | 账号额度已满或权益过期(建设备时) | 到控制台查看剩余额度与有效期,或联系客服 |
-40403 | 403 | 在不支持的接口上传了 callbackUrl / pushHistory | 回调地址只在 api/long/start、api/long/updateCallbackURL 与 POST /qingluan/instances/set-callback 上有效 |
-40404 | 404 | appid 不存在或不属于你 | 核对控制台「实例与回调」里的值 |
-42900 | 429 | 调用过于频繁 | 按响应头 Retry-After 退避;确需更高频率请联系客服 |
-50000 | 502 | 回调登记失败 | 稍后重试 |
-50002 | 502 | 服务暂时不可用(超时 / 网络异常 / 未就绪) | 退避重试;持续失败请联系客服 |
⚠️ -40404 对「不存在」和「不属于你」是同一句话,这是有意的:区分开等于给攻击者一个 枚举探针,可以拿别人的 appid 试出哪些是真实存在的。
Webhook 回调服务
事件走中转,不是直连:
企业微信 ──▶ 青鸾接收器 ──┬──▶ 回调日志(控制台,原样留存 24 小时)
└──▶ 转发到你的地址(青鸾信封 + HMAC 签名)企业微信事件只推给青鸾接收器 —— 这个地址一号一个、扫码上号时自动注册、不可更改也不可关闭。 它是回调日志、归属校验、掉线自动重连三件事的共同前提。
⚠️ 回调只有入站。 你自己通过接口发出去的消息不会产生回调 —— 这是正常的, 不是回调丢了。要确认自己发的消息,用
api/message/sync拉取。
配置转发地址(三选一,优先级从高到低):
| 方式 | 作用范围 |
|---|---|
| 控制台「实例与回调」→ 某个实例「配置回调」 | 只这个号 |
| 控制台「实例与回调」→ 账号默认转发地址 | 所有没单独配的号 |
POST /qingluan/instances/set-callback(body: appid + callbackUrl) | 只这个号 |
请求鉴权(可选):在控制台配置回调地址时,可以开启 Bearer 鉴权并填写由你自己 管理的 Token。开启后,青鸾的每次回调 POST 都会携带:
Authorization: Bearer <你配置的 Token>
Token 会加密保存,保存后不再回显;留空表示保持原 Token,关闭鉴权或清空对应回调地址 会同时清除它。实例没有独立回调地址时,会连同地址和签名密钥一起继承账号默认配置。 Bearer 鉴权与下方 HMAC 验签彼此独立,开启 Bearer 后仍会保留全部 HMAC 请求头。
保存时的测试回调:在控制台保存非空回调地址时,青鸾会立即向该地址直发一次 QingluanCallbackTest,正文事件内容固定为“青鸾回调测试”。测试请求沿用正式回调的 HMAC 请求头;如果该地址开启了 Bearer 鉴权,也会携带相同的 Authorization 请求头。
实例回调测试会携带该实例的真实 appid:
{
"appid": "<实例 appid>",
"event_type": "QingluanCallbackTest",
"test": true,
"events": [
{
"id": "cbtest_<唯一值>",
"content": "青鸾回调测试",
"created_at": "<UTC ISO 8601 时间>"
}
]
}账号默认回调不对应某一个实例,因此测试正文不含任何实例标识:
{
"event_type": "QingluanCallbackTest",
"test": true,
"events": [
{
"id": "cbtest_<唯一值>",
"content": "青鸾回调测试",
"created_at": "<UTC ISO 8601 时间>"
}
]
}这是一条一次性连通性测试:不进入正式投递队列、不自动重试,也不出现在回调预览里。 配置会先保存;即使测试超时、连接失败或目标返回非 2xx,也不会回滚。界面显示测试成功 只表示目标返回了 2xx,不代表目标一定已经完成验签或业务处理。
转发信封:
{
"appid": "<与业务接口用的是同一个值>",
"event_type": "Msg",
"events": [ ... ]
}event_type 是事件类型(见下方「事件类型」)。一个地址收多个号时,按 appid 区分。
验签(可选,不验也能正常收):请求头 X-Qingluan-Timestamp(Unix 秒)与 X-Qingluan-Signature。
signature = HMAC-SHA256(key = callback_secret 的 UTF-8 字节,
msg = timestamp 的 ASCII 字节 + b"." + 原始请求体字节)
的十六进制小写三个容易签错的地方:
timestamp和一个点号也在被签的消息里,不是只签请求体。- 用收到的原始字节,不要先反序列化再重新序列化 —— 键顺序和空格一变签名就对不上。
callback_secret直接当 UTF-8 字节做密钥,不做 base64 / hex 解码。
固定测试向量(拿它先把本地实现校准了,再去接真实回调):
| 项 | 值 |
|---|---|
callback_secret | whsec_demo |
X-Qingluan-Timestamp | 1757000000 |
| 原始请求体 | {"appid":"we_demo","event_type":"Msg","events":[]} |
| 期望签名 | cc555c9b81e1c9652c00df7d53e2e6711996d614df665dbe3dab0454fa48f41b |
时间窗:我方不限制 X-Qingluan-Timestamp 的新旧(重试最长可能在 6 小时后到达, 卡窗会把正常重投判成伪造)。建议你按自己的容忍度校验,取 ±10 分钟是常见选择; 重投场景下请以事件去重为主、时间窗为辅。
密钥在控制台可见,账号级与实例级各有一个,与地址成对使用。轮换密钥后, 尚未送达的重试会用新密钥签名 —— 旧密钥不再保留。
投递保证:至少一次(at-least-once)
- 投递失败(超时、连接失败、非 2xx)会按 30 秒 / 2 分 / 10 分 / 30 分 / 2 小时 / 6 小时 退避重投,共 6 次;只有 2xx 才算送达(3xx 不算 —— 我们不跟随跳转)。
- 6 次都失败进死信,可在控制台「回调投递」里查看并手动重放。
- 因此同一条事件你可能收到多次(重投、或平台自身重推)。请按
events[].id做幂等;没有id的事件(如GapClosed)按你自己的业务键去重。 - 请先落库再回 200。你回了 2xx 我们就认为送到了,不会再投第二次。
事件类型
event_type(一级事件,标明这一包是什么):
| event_type | 含义 |
|---|---|
Msg | 消息 / 群事件,具体看 events[].contentType |
GapConnected / GapSucceed | 长连接已建立 / 就绪 |
GapClosed | 长连接断开,code 见下表 |
BigFileUploadCompleted / BigFileUploadFailed | 大文件上传结果 |
Error | 错误 |
InstanceOffline / InstanceRecovered | 青鸾自己发的账号状态变化,见下方「账号状态事件」 |
GapClosed 的 code:
| code | 含义 | 账号是否还在线 |
|---|---|---|
-11001 | 需断线重连后判断 | 可能仍在线,青鸾会自动重连确认 |
-11002 | 已在其他设备上登录 | 否 |
-1008 | 移动端主动退出登录 | 否 |
-1 | 长连接异常,见 message | 否 |
0 | 用户手动关闭长连接 | 否 |
账号状态事件(青鸾自产)
上面那些是能力层推给你的。InstanceOffline / InstanceRecovered 不一样, 是青鸾在探测到账号状态变化时自己发的 —— 因为能力层会静默断: 连接掉了却不发 GapClosed,只表现为消息突然不来了。青鸾每分钟探一次, 断了先自动尝试免扫码重连(沿用登录时的同一出口),救不回来才通知你。
信封与其他事件完全一致,你已有的解析和按 events[].id 的幂等逻辑不用改:
{
"appid": "we_xxxxxxxxxxxxxxx",
"event_type": "InstanceOffline",
"events": [{
"id": "InstanceOffline:we_xxxxxxxxxxxxxxx:1757500000",
"reason": "长连接中断,自动重连未成功",
"action_required": "relogin",
"detected_at": "2026-09-10T13:31:00+00:00",
"last_seen_at": "2026-09-10T12:56:36+00:00"
}]
}action_required 决定你该怎么处理:
| 值 | 含义 | 该做什么 |
|---|---|---|
relogin | 长连接断了,登录态多半还在 | 到控制台点「重新登录」,通常免扫码 |
manual | 风控 / 已在其他设备登录 / 手机端主动退出 | 必须重新扫码。这类反复重连会加重风控,青鸾已停止自动重试 |
InstanceRecovered 的 events[] 带 recovered_at、offline_seconds (本次中断时长)和 auto_reconnected(是否由青鸾自动救回)。
⚠️ 只在状态翻转时各推一条,不会每轮重复推。短暂抖动被当轮重连救回的, 不会打扰你。
events[].messageType(会话维度):0=私聊 · 1=群聊 · 3=应用/系统会话。 ⚠️ 不要用 roomId != 0 判群聊 —— messageType=3 的 roomId 与 fromUserId 同值, 用它判会把应用会话误判成群聊。一律以 messageType 为准。
events[].contentType(消息类型):
| 文本 | 0 2 文本(content 是 [{type,text}] 数组) |
| 媒体 | 14 101 图片 · 16 语音 · 23 103 视频 · 15 102 文件 · 20 大文件 · 29 104 表情 |
| 富消息 | 13 链接 · 6 位置 · 41 名片 · 78 小程序 · 4 聊天记录 · 26 红包 · 215 笔记 · 579 会议卡片 · 10 邮件通知 |
| 群事件 | 1001 修改群名 · 1002 成员进群 · 1003 移除群成员 · 1005 退群 · 1006 群新增 · 1011 群操作提示 · 1022 群设置/群主变更 · 1023 群解散 · 1029 进群邀请申请 · 1043 群管理员变动 · 2118 群信息变动 · 213 群接龙 · 2308 群公告变更 · 2063 撤回 |
| 通话 | 2324 通话通知 · 40 通话结果 · 2350 通话结束 · 2412 通话记录提示 · 503 2120 2166 通话信令 |
| 其它 | 2001 已读回执 · 2357 好友申请 |
| 纯信令 | 2002 2055 2104 2114 2115 2130 2131 2132 2160 2161 2180 2186 2188 2201 2215 2313 —— content 为空,无可读内容,建议直接过滤 |
三条要点,照做能少走弯路:
- 进出群事件靠载荷形态区分:
1002成员进群与1003移除群成员同构 (fromUserId=操作者,content=被操作成员的 uid);1005退群则fromUserId=退群者本人、content为空。 ⚠️1006是群新增、不是退群,别拿它判退群。 ⚠️2118群信息变动的content是空的,但它不是无意义的信令 —— 例如微信成员自己退群就会推它。别按"载荷为空"一律过滤掉。
- 群系统事件的正文是 protobuf,句子的主语是一个数字 id,不是文字。 例:
1022群主变更解出来是{1: <userId>, 2: "已经成为新的群主"}—— 光读文字看不出是谁。只有 id,没有名称,要显示成人名得自己拿 id 去查成员资料。 - 群公告的正文和事件是分开的两条。 公告正文是一条
contentType 2(文本), 靠flag的0x10000位与普通消息区分;contentType 2308(群公告变更) 只带那条公告消息的 id、不带正文 —— 想显示内容得拿这个 id 回查那条消息。 - 一次音视频通话会推 8~10 条事件,不是一条。发起固定 3 条;结束按结局不同,
contentType 40的正文就是结局本身:
| 结局 | 40 的正文 | 是否有 2412 | 总条数 |
|---|---|---|---|
| 主叫取消 | 对方已取消 | 否 | 8 |
| 被叫拒接 | 已拒绝 | 否 | 9 |
| 超时未接 | 未接听 | 否 | 9 |
| 接通后挂断 | 通话时长 | 是 | 10 |
2350 每种结局都会推,代表通话结束;2412 只在接通后出现(录音与 AI 总结提示)。
来电渠道不同,信令类型也不同:企业微信内部通话是 2120 + 503; 微信用户打来是 2166 + 503,且不推 2350 与 2412。 503 是两种渠道共有的那条,2120/2166 按渠道区分。 ⚠️ 两者成对出现、内容相同 —— 请按通话会话号去重、只处理其一, 否则每通电话会重复计一次。
- 资料变更只推信令、不推内容。 改备注 / 标签 / 描述 / 聊天标签,会收到
23132160216121862131等一串信令,但它们的content是空的 —— 企业微信只告诉你「有东西变了」,不告诉你变成什么。要拿新值必须收到信令后 主动调接口查,别指望从回调里解析。
2357好友申请的content是结构化字段(不是 hex):corpId/uin/corpName/customerName/source。 其中source直接写明来源(例如微信),是判断对方是不是微信用户最可靠的字段。
接口清单
左侧列出的是当前能力层已开放的接口。由于数据面是原样透传,清单可能滞后于能力层 —— 新增接口即使还没出现在文档里,也可以直接按 POST /qingluan/api/{模块}/{动作} 调用。