☰
飞书群机器人Webhook消息发送失败排查与稳定集成实践
2026/10/20 16:51:13 网站建设 项目流程

1. 从一个“消息发不出去”的深夜说起

飞书群机器人(Custom Bot)的 Webhook 看起来是最好上手的集成方式之一:在群里点几下,拿到一条 URL,往上一贴,curl一发,消息就进群了。我第一次接的时候也是这么想的,结果从晚上十点折腾到凌晨一点,消息要么石沉大海,要么返回一堆看不懂的错误码,要么格式全乱。后来陆续在几个项目里反复踩坑,才把这条链路摸清楚。

这篇内容就是把我踩过的坑、排查思路和最终稳定跑通的方案完整摊开讲。核心关键词是飞书 Webhook、群机器人、消息卡片、签名校验、频率限制。它适合三类人:刚接触飞书机器人、准备把告警/通知接入飞书群的开发者;已经接了但消息时好时坏、想搞清楚根因的人;以及需要把 Webhook 做成稳定生产级通知通道的运维或后端同学。读完你应该能独立完成从建机器人到消息稳定送达的全过程,并且知道每个参数为什么这么设。

先说一个反直觉的结论:飞书 Webhook 发不出去,八成不是网络问题,而是消息体格式、签名、或者安全设置三者之一出了问题。很多人第一反应是“是不是被墙了”“是不是 URL 过期了”,然后开始瞎试,其实方向从一开始就偏了。下面我按真实排查顺序,一层层拆。

2. 建机器人这一步,安全设置选错后面全是坑

2.1 三种安全设置到底该选哪个

在飞书群里添加自定义机器人时,安全设置有三个选项:自定义关键词、IP 白名单、签名校验。很多人随手勾一个就过了,结果后面发消息一直失败,回头才发现是这里的问题。

我先把三者的适用场景讲清楚,这是后面所有坑的源头:

安全设置生效逻辑适合场景主要坑点
自定义关键词消息文本里必须包含设定词固定格式的简单通知关键词大小写、位置敏感,卡片消息容易漏
IP 白名单只有白名单 IP 能调用服务器 IP 固定的后端服务出口 IP 变动、多机部署时漏配
签名校验每次请求带时间戳+签名生产环境、公网调用时间戳单位、签名算法最容易写错

我的建议很直接:只要不是纯内网固定 IP 的简单场景,一律用签名校验。关键词方式看着简单,但它对消息内容有强约束,你发个富文本卡片,关键词藏在某个字段里没被识别到,消息就直接被拒,而且报错信息往往不明确,排查起来很痛苦。IP 白名单在容器化、弹性伸缩的环境里基本没法用,出口 IP 随时会变。

签名校验虽然多写几行代码,但它是唯一不依赖消息内容、也不依赖网络环境的方式,最稳。

2.2 拿到 Webhook URL 之后先别急着写代码

拿到那条https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxx的 URL 之后,先做一件事:用最原始的方式验证它通不通。不要一上来就写业务代码,那样出了问题你分不清是 URL 的问题还是代码的问题。

最省事的验证方式是用命令行直接发一条最简单的文本消息:

curl -X POST -H "Content-Type: application/json" \ -d '{"msg_type":"text","content":{"text":"测试消息"}}' \ https://open.feishu.cn/open-apis/bot/v2/hook/你的hook_id

如果这一步就失败了,那问题一定在安全设置或 URL 本身,跟你的业务代码无关。如果这一步成功,说明通道是通的,后面再出问题就是消息体或签名的事。这个“分层验证”的思路,是我排查所有 Webhook 问题的基本方法,能帮你快速缩小范围。

注意:如果你开了签名校验,上面这条不带签名的 curl 一定会失败,返回签名相关的错误码。这是正常的,说明签名校验生效了。验证时要么先临时关掉签名,要么直接带上签名参数测。

2.3 机器人被移出群聊或群被解散

这个坑比较隐蔽。Webhook URL 是绑定到具体群聊的,如果机器人被移出群、或者群被解散、或者群成员把机器人删了,URL 就会失效,返回的错误码通常是19001或类似的无效凭证类错误。这种情况你代码怎么改都没用,得重新在群里添加机器人拿新 URL。

我遇到过一次,测试环境一切正常,上线后告警全丢,查了半天代码,最后发现是运维同学清理群成员时把机器人踢了。所以上线前一定要确认机器人还在群里,并且给它一个不会被误删的备注名。

3. 签名校验:90% 的失败都栽在这几行代码上

3.1 签名到底是怎么算出来的

飞书的签名机制是这样的:你把时间戳 + "\n" + 密钥拼成一个字符串,然后用 HMAC-SHA256 算法、以这个拼接串为密钥、对空字符串做摘要,得到的结果就是签名。听起来有点绕,我用代码说清楚。

关键点在于:密钥(secret)是拼接串的一部分,而不是 HMAC 的输入数据。很多人第一反应是“用 secret 当 key,对 timestamp 做 HMAC”,这是错的,方向反了。正确的逻辑是:

import hashlib import base64 import hmac import time def gen_sign(secret): timestamp = str(int(time.time())) # 拼接串:时间戳 + 换行 + 密钥 string_to_sign = f"{timestamp}\n{secret}" # 以拼接串为 key,对空字节做 HMAC-SHA256 hmac_code = hmac.new( string_to_sign.encode("utf-8"), digestmod=hashlib.sha256 ).digest() sign = base64.b64encode(hmac_code).decode("utf-8") return timestamp, sign

我第一次写的时候就是搞反了,把 secret 当 key、timestamp 当 data,结果签名永远对不上,返回19021签名校验失败。这个错误码是签名问题里最常见的,看到它基本可以锁定是签名算法写错了。

3.2 时间戳单位:秒还是毫秒

这是第二个高频坑。飞书签名用的时间戳是秒级(10 位),不是毫秒级(13 位)。很多语言默认给的是毫秒,比如 JavaScript 的Date.now()返回的就是毫秒。如果你直接把毫秒时间戳拿去算签名,服务端校验时会认为时间戳偏差过大,直接拒绝。

而且飞书对时间戳的有效期有要求,请求时间与时间戳的偏差不能超过 1 小时(3600 秒)。这意味着你不能缓存一个签名反复用,每次发消息都得重新算。我见过有人为了省事把签名写死成常量,结果一小时后全部失效,还以为是服务端抽风。

// JavaScript 正确写法 const crypto = require('crypto'); function genSign(secret) { const timestamp = Math.floor(Date.now() / 1000).toString(); // 注意除以1000 const stringToSign = `${timestamp}\n${secret}`; const sign = crypto .createHmac('sha256', stringToSign) .update('') .digest('base64'); return { timestamp, sign }; }

3.3 签名参数放在哪里

算出来的timestamp和sign不是放在请求头里,而是放在请求体的顶层字段,和msg_type、content平级:

{ "timestamp": "1700000000", "sign": "计算出来的签名字符串", "msg_type": "text", "content": { "text": "带签名的消息" } }

我踩过一次坑,把 timestamp 和 sign 塞进了 header,服务端完全读不到,一直报签名缺失。飞书的文档里这个位置写得不算特别显眼,容易看漏。记住:签名参数在 body 顶层,不在 header。

3.4 一个完整的带签名请求示例

把上面几点串起来,一个能直接跑的 Python 请求长这样:

import requests import json def send_feishu(webhook, secret, text): timestamp, sign = gen_sign(secret) body = { "timestamp": timestamp, "sign": sign, "msg_type": "text", "content": {"text": text} } resp = requests.post( webhook, headers={"Content-Type": "application/json"}, data=json.dumps(body) ) return resp.json()

实测下来,只要gen_sign写对、时间戳用秒、参数放对位置,签名校验基本不会出问题。这三个点任意一个错了,都会失败,而且错误码不一定能精确指向具体原因,所以建议你先用官方文档里的示例值对一遍签名结果,确认算法无误再接入业务。

4. 消息体格式:文本、富文本、卡片各有各的脾气

4.1 msg_type 和 content 必须严格对应

飞书的消息类型(msg_type)和内容结构(content)是强绑定的,用错组合直接报错。常见的对应关系:

msg_typecontent 结构说明
text{"text": "内容"}纯文本,最简单
post富文本嵌套结构支持标题、多段落、链接
interactive卡片 JSON功能最强,结构最复杂
image{"image_key": "xxx"}需先上传图片拿 key

最常见的错误是msg_type写text,但content里塞了卡片结构,或者反过来。服务端会返回9499之类的格式错误。排查时第一件事就是核对 msg_type 和 content 是否匹配。

4.2 富文本 post 的嵌套结构容易写错

富文本(post)的结构是三层嵌套:语言 → 段落 → 元素。很多人第一次写会少一层或者多一层。正确结构大致是这样:

{ "msg_type": "post", "content": { "post": { "zh_cn": { "title": "标题", "content": [ [ {"tag": "text", "text": "第一段文字 "}, {"tag": "a", "text": "链接", "href": "https://example.com"} ] ] } } } }

注意content是个二维数组,外层是段落,内层是段落里的元素。我见过有人写成了一维数组,结果整条消息渲染异常或者直接报错。另外zh_cn这个语言 key 不能省,即使你只发中文。

4.3 卡片消息:好看但最容易翻车

消息卡片(interactive)是飞书通知里最常用的,因为可以带颜色、按钮、分栏,告警场景特别合适。但它的结构也最复杂,坑最多。

第一个坑是卡片版本。飞书卡片有 1.0 和 2.0 两个版本,结构不兼容。你用 2.0 的语法去发 1.0 的卡片,或者反过来,都会渲染失败。建议统一用 2.0,在卡片 JSON 里显式声明版本。

第二个坑是字段名大小写和层级。卡片 JSON 里elements、fields、text这些字段名必须完全正确,少一个字母或者大小写错了,卡片就渲染不出来,而且报错信息往往很模糊。

第三个坑是模板变量。如果你用卡片模板(template),变量替换的语法是${变量名},但变量必须在template_variable里传,且类型要匹配。传了个字符串给期望数字的变量,卡片会显示异常。

我的经验是:卡片先用飞书的卡片搭建工具可视化编辑,导出 JSON,再往代码里贴。手写卡片 JSON 出错率极高,可视化工具能帮你避开大部分结构问题。

4.4 换行和特殊字符的处理

文本消息里想换行,直接写\n就行,但要注意 JSON 转义。如果你在代码里拼字符串,\n要写成\\n才能正确传递。这个坑在动态拼接告警内容时特别常见,比如把多行日志拼进消息,结果全挤成一行。

另外,消息内容里如果包含{、}、$这类字符,在卡片模板里可能被当成变量语法解析,导致渲染错误。动态内容建议做一次转义,或者干脆用文本消息而不是卡片。

5. 频率限制与错误码:消息发太快会被静默丢弃

5.1 飞书 Webhook 的频率限制

飞书自定义机器人有频率限制,默认是每分钟最多 100 条消息(具体数值可能随官方调整,以实际为准)。超过限制后,后续消息会被拒绝,返回频率相关的错误码。

这个坑在告警场景里特别致命。比如你的服务突然大面积报错,告警系统瞬间触发几百条通知,全往一个群里灌,结果超过限制,后面的告警全丢了,而你还在纳闷为什么没收到。正确做法是做告警聚合和限流:相同类型的告警在时间窗口内合并成一条,或者用队列削峰,控制发送速率。

我一般的做法是在发送层加一个简单的令牌桶或者固定间隔,保证每秒不超过 1-2 条,宁可稍微延迟也不要丢消息。

5.2 常见错误码对照表

把我在实践中遇到过的错误码整理成表,方便你快速定位:

错误码含义排查方向
19001无效的 webhookURL 错误、机器人被移出群
19021签名校验失败签名算法、时间戳单位、参数位置
9499消息格式错误msg_type 与 content 不匹配
19003频率超限发送太快,需限流
19002关键词不匹配消息未包含设定的关键词

看到错误码先别慌,对照这张表基本能锁定方向。最忌讳的是不看返回体直接重试,那样只会浪费时间和触发限流。

5.3 一定要打印返回体

这是我最想强调的一条经验:每次发送后,务必把服务端返回的 JSON 完整打印出来。飞书的返回体里code和msg字段会告诉你具体哪里错了,很多人只看 HTTP 状态码,200 就以为成功了,其实 body 里code是非 0 的失败。

resp = requests.post(webhook, json=body) result = resp.json() if result.get("code") != 0: print(f"发送失败: {result}") # 一定要打出来

我早期就是因为没打返回体,白白排查了两个小时,最后发现返回里明明白白写着签名错误。

6. 把 Webhook 做成稳定生产通道的几个实操心得

6.1 封装成独立模块,别散落在业务代码里

Webhook 发送逻辑一定要封装成一个独立函数或类,统一处理签名、重试、限流、日志。散落在各处的话,改一个参数要满项目找,而且容易出现有的地方带签名有的地方不带。我的做法是封装一个FeishuNotifier类,对外只暴露send_text、send_card几个方法,内部把签名和错误处理都吃掉。

6.2 重试要克制,别把限流触发得更狠

网络抖动导致的失败可以重试,但重试次数不要超过 2-3 次,且要加退避间隔。无脑重试在限流场景下会雪上加霜。我的策略是:签名错误、格式错误这类确定性失败不重试(重试也没用),只有网络超时、5xx 这类临时性失败才重试,且间隔递增。

6.3 密钥不要硬编码

Webhook URL 和 secret 都是敏感信息,绝对不要硬编码在代码里提交到仓库。用环境变量或配置中心管理。我见过有人把带 secret 的代码推到公开仓库,结果被人拿去乱发消息。这是安全底线,不能省。

6.4 上线前做一次“断网演练”

正式接入告警前,我会故意把 Webhook 配错(比如改错 secret),观察系统在发送失败时的行为:是静默吞掉,还是有日志、有降级、有兜底通知。通知通道本身也需要被监控,否则它挂了你还不知道。可以加一个定时任务,每隔一段时间发一条心跳消息,确认通道存活。

6.5 卡片内容别塞太多,注意可读性

告警卡片信息量要克制。我见过把整个堆栈、几十行日志全塞进一张卡片的,手机上根本没法看。卡片只放关键信息(服务名、错误类型、时间、跳转链接),详细内容放日志平台,卡片里给个链接。这样既清爽又实用。

7. 我个人的几条经验总结

折腾飞书 Webhook 这几年,最大的体会是:它本身不难,难的是细节。签名算法、时间戳单位、参数位置、消息体结构、频率限制,每一个都是小点,但任何一个错了都会让你卡很久。我的建议是,第一次接入时严格按“先验证通道、再验证签名、最后调格式”的顺序来,每步都打印返回体,别跳步。

另外,别迷信“复制粘贴就能用”的示例代码。不同语言、不同版本的库,签名实现细节可能不一样,一定要自己跑一遍验证。我现在的习惯是,任何新的 Webhook 接入,先写一个最小可运行脚本,确认能发出消息,再往业务里集成。这个习惯帮我省下了大量排查时间。

最后分享一个小技巧:如果你不确定签名算得对不对,可以拿一个固定的 timestamp 和 secret,手动算一遍,和官方文档里的示例结果对比。对上了,说明算法没问题,剩下的就是参数位置和消息格式的事了。这个“锚点验证法”在排查任何加密签名问题时都好用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询