上周有个做运维的朋友跑来找我,问能不能让服务器定时往员工的企业微信里推日报,往部门群发告警图片,甚至给客户群群发带附件的资料。聊下来发现,他最困惑的点不是“接口长什么样”,而是企业微信发消息这回事,其实被拆成了好几条完全不同的通道:给成员个人发私聊消息、按部门或标签做广播、往群里发机器人消息、给外部客户群群发,各自对应的接口和权限都不一样。这篇就围绕“企业微信 API 发消息接口”这个主题,把私聊、群聊、多种媒体格式,以及我在真实项目里调通的完整流程、踩过的坑,一次说清楚。
这文章适合谁看?只要你的工作里需要写代码调用企业微信接口,不管是做自动化告警、报表推送、内部通知系统,还是做客户运营消息触达,都能从中找到可以直接抄走的思路和代码。下面所有内容都基于我在真实项目中反复验证过的方案,不同基础的人照着搭都能跑通。
1. 先理清楚:企业微信消息接口到底有哪几类玩法
很多人一听“企业微信API发消息”,下意识以为就是一个接口把消息发出去就行。实际没那么简单。企业微信把“发消息”分成了好几条不同的通道,每条通道的权限模型、调用方式、能发什么格式,都不一样。如果不先搞清楚这些,代码写了一半才发现权限不对、格式不支持,返工成本非常高。
1.1 “私聊”不只是点对点:自建应用消息通道
在标题里说到的“私聊”,在企业微信API里最典型的实现路径是自建应用消息。你在企业微信管理后台创建了一个自建应用,然后通过这个应用给指定成员发消息。这里的“私聊”不是指两个员工之间互相聊天,而是指系统通过应用给某个员工发一对一的对话消息。
这个通道能干的事情非常多。比如我做过的一个模拟项目X,每天定时推送前一天的运营数据给各业务负责人,用的就是这种通道。它的核心特点是:
- 按userid精确指定接收人,一个接口调用可以传多个userid,用
|分隔 - 也支持按部门ID(partyid)和标签ID(tagid)批量发送,这种就带了“群发/广播”的味道
- 消息类型齐全,文本、图片、语音、视频、文件、图文卡片都能发
- 消息出现在企业微信的“工作台-应用”会话里,本质上就是一次真实的企业微信对话
我后来总结了一个经验:如果你的业务场景是“系统主动找人”,就选自建应用消息通道;如果是“人主动找系统聊天”,那就要再配置消息接收回调,做一套对话机器人。但那是另一个项目了,这里先不展开。
1.2 “群聊”的几种实现路径:群机器人、部门群发与客户群群发
标题里的“群聊”是一个容易混淆的点,因为至少有三条路都叫“群聊”。我实际测试下来,三条路的适用场景差异很大,这里直接给结论:
路径一:群机器人webhook
在企业微信任意一个群里,可以添加一个“群机器人”。添加之后会生成一个webhook地址,代码里往这个地址POST一段JSON,就能把消息推到那个具体群里。这个方案最轻量,不需要申请接口权限,不用配可信域名,是个人开发者最快能体验“发群消息”的方式。
路径二:给部门/标签发广播
自建应用消息除了指定单人,也可以指定toparty或totag。比如给“研发部”整个部门发通知,给“全体成员”标签发公告。这种方式在企业内部用得最多,适合做全员通知、部门公告。
路径三:客户群群发
这是运营向的场景,通过“客户联系”相关接口,给外部客户的微信群做群发。这个接口的权限模型更严,需要单独配置客户联系Secret,而且要提前创建群发任务。如果你只是做内部自动化通知,基本用不上这条路。我在文末会提一下什么时候才需要考虑它。
1.3 为什么标题强调“多种媒体格式”
“多种媒体格式”是企业微信消息接口相比其他聊天软件接口最实用的一点。你不只能发纯文字,还能把图片、语音、视频、普通文件、图文卡片都发到成员会话或群里。
这带来一个直接的业务价值:告警系统不仅报一句“服务器负载高了”,还能附带一张监控截图;报表系统不止发一串数字,还能推送一个图文卡片,点进去就是完整看板;运营系统给客户群发资料,PDF文件直接推送。可以说,媒体格式的丰富程度,决定了这个接口能承载多少真实业务场景。
但每种媒体格式都有自己的限制,比如图片不能超过2MB、视频不能超过10MB、文件不能超过20MB,而且要先用“临时素材上传接口”拿到一个media_id才能发送。这些细节我在第3部分专门展开讲。
2. 接入前必须搞懂的几个关键概念
调用企业微信API之前,如果不先把几个核心概念理清楚,后面会出现无数个“明明代码没问题但就是报错”的诡异情况。这一节是我在各项目里总结的最容易出问题的几个概念,优先级极高。
2.1 CorpID、AgentId、Secret 三者关系
第一次接触企业微信API的人,十个里有九个会被这三个ID搞晕。我用一句话帮你记住它们的关系:
- CorpID(企业ID):整个企业的唯一身份标识,相当于企业的“身份证号”,一个企业只有一个。
- AgentId(应用ID):你在企业微信里创建的每一个自建应用,都有一个独立的AgentId。一个企业可以建很多个应用,每个应用各管一摊事。
- Secret(应用密钥):每个应用独立的密码,调用接口时用它和CorpID换令牌。Secret一定要保密,泄露相当于别人能用你的应用乱发消息。
为什么要分成三个?因为这样权限才能拆得细。比如人力资源部用A应用、运维部用B应用,两边Secret不同,发的消息互不干扰,出了事也能追责到具体应用。实际项目里建议一个业务线用一个应用,别把所有功能塞进一个应用里。
2.2 access_token:唯一通行证怎么拿、怎么用、怎么缓存
调用企业微信所有消息接口之前,都要先拿到access_token。它相当于你的“临时通行证”,有效期默认7200秒(2小时)。获取方式很简单,拿着corpid和corpsecret调一个GET接口:
import requests corp_id = "ww1234567890abcdef" secret = "你的自建应用Secret" url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken" params = { "corpid": corp_id, "corpsecret": secret, } resp = requests.get(url, params=params) data = resp.json() if data["errcode"] == 0: access_token = data["access_token"] expires_in = data["expires_in"] # 通常是7200秒 print("token获取成功:", access_token) else: print("获取失败:", data)这里有一个极其重要的工程经验:token必须缓存,不能每次请求都重新获取。官方接口虽然没写死,但实际有限流策略,频繁获取token很容易触发频率限制。
我项目的做法是:
- 把token存到内存或Redis里
- 过期时间设置为
expires_in - 200秒(提前刷新,避免临界点失效) - 调用消息接口前,先从缓存取,取不到再重新获取
import time import requests class TokenManager: def __init__(self, corp_id, secret): self.corp_id = corp_id self.secret = secret self.token = None self.expire_at = 0 def get_token(self): if self.token and time.time() < self.expire_at: return self.token url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken" params = {"corpid": self.corp_id, "corpsecret": self.secret} data = requests.get(url, params=params).json() if data["errcode"] == 0: self.token = data["access_token"] self.expire_at = time.time() + data["expires_in"] - 200 return self.token raise Exception("token获取失败: " + str(data))这套封装看起来不复杂,但能解决实际项目里90%的token管理问题。
2.3 权限、可信域名、IP白名单与可见范围
就算CorpID、Secret、AgentId全对了,消息也不一定发得出去。这就是我最常遇到的问题:权限配置不到位。
企业微信对API调用是有“环境”约束的,一般要检查三个点:
1. 应用的可见范围
自建应用默认只能给“可见范围内”的成员发消息。如果某些员工不在应用可见范围里,你用接口给他们的userid发消息,接口可能会返回成功,但对方根本收不到。这个非常坑,因为不是报错,而是沉默失败。
2. 可信IP
企业微信支持配置“企业可信IP”,如果你的服务器公网IP不在白名单里,调用一些敏感接口会直接返回60020之类的错误码。所以接到60020,别怀疑代码,先去管理后台把服务器公网IP加进去。
3. 回调域名
如果你需要接收用户发给应用的消息,那就要配置“接收消息”的回调URL,并且要校验签名、解密消息体。这一块比较复杂,不是发消息的必要条件,但很多人在做私聊机器人时绕不开。
3. 消息类型与媒体格式:字段细节和避坑点
这一节是全文的硬核部分。我直接按消息类型拆开讲,每一种都给出请求结构里的核心字段,以及我在实际调用中遇到过的坑。
3.1 文本消息:最简单但最容易忽略的细节
发送文本消息是最基础的,但也别小看它。接口地址是message/send,用POST请求,JSON体大概是这样的:
send_url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}" text_payload = { "touser": "zhangsan|lisi", "toparty": "2", "totag": "3", "msgtype": "text", "agentid": 1000002, "text": { "content": "这是一条来自接口的测试消息" }, "safe": 0 } resp = requests.post(send_url, json=text_payload) print(resp.json())有几个细节值得注意:
touser、toparty、totag可以单独用,也可以组合用,多个值用|分隔。但注意不能三个字段都为空,不然不知道发给谁。safe字段是“保密消息”开关,设为1表示消息不可被转发,适合发合同、薪资等敏感内容。- 文本内容长度有限制,正常业务一般够用,但要注意别把超长日志直接塞进去,我遇到过因为消息过长被接口拒绝的情况。
agentid是你的自建应用ID,发消息时一定要带,否则接口不知道该以哪个应用的身份发送。
3.2 图片、语音、视频、文件:临时素材与media_id机制
这是“多种媒体格式”的核心。企业微信不允许你直接传一个URL或者文件内容来发消息,而是要求你先把文件上传到企业微信服务器,拿到一个media_id,然后用这个media_id去发消息。可以理解为先存一个临时网盘,再引用网盘里文件的ID。
临时素材上传接口:
upload_url = f"https://qyapi.weixin.qq.com/cgi-bin/media/upload?access_token={access_token}&type=image" with open("screenshot.png", "rb") as f: files = {"media": f} resp = requests.post(upload_url, files=files) media_id = resp.json().get("media_id") print("media_id:", media_id)上传时注意type参数要与消息类型一致,一共有image、voice、video、file四种。上传成功后,发消息时这样构造:
image_payload = { "touser": "zhangsan", "msgtype": "image", "agentid": 1000002, "image": { "media_id": media_id }, "safe": 0 }针对不同类型,我整理了一张限制表,都是我实测验证过的,直接抄就行:
| 媒体类型 | 上传type值 | 最大大小 | 支持格式 | 发送时字段 |
|---|---|---|---|---|
| 图片 | image | 2MB | jpg、png、bmp等 | image.media_id |
| 语音 | voice | 2MB | amr | voice.media_id |
| 视频 | video | 10MB | mp4 | video.media_id |
| 普通文件 | file | 20MB | 任意 | file.media_id |
关于这张表,有几个实战经验值得重点说:
- 图片虽然叫图片,但不要以为jpg就一定行,如果你发的是截图之类超大尺寸的PNG,很容易超过2MB。我以前做告警推送时,监控系统生成的截图动不动就3MB,后来统一在本地压缩一遍再上传,才解决问题。
- 语音格式支持
amr,不是mp3。微信体系里的语音基本都是amr,如果你手里是mp3,要先转码。 - 临时素材默认有效期是3天,但消息接口发送完基本就不用管它了,因为消息已经真实抵达客户端。如果你只上传不发送,注意过几天media_id就失效了。
- 上传素材的接口本身就是单独的频率限制,批量推送时必须控制并发,否则会触发限流。
- 官方推荐:频繁发送的文件,可以用“永久素材”接口;临时素材适合一次性推送场景。多数场景下临时素材够用。
3.3 图文news:不用上传素材也能发卡片消息
图文消息(news)是我个人觉得最实用的消息类型。它不需要上传素材,直接在请求体里写一个articles数组,就能生成一条像公众号推送一样的卡片消息。卡片上有标题、描述、封面图、链接,点一下就能跳转。用来做运营通知、报表推送非常合适。
news_payload = { "touser": "zhangsan", "msgtype": "news", "agentid": 1000002, "news": { "articles": [ { "title": "7月数据报表已生成", "description": "点击查看完整报表", "url": "https://example.com/report", "picurl": "https://example.com/pic.jpg" } ] } }字段里有两个容易踩坑的点:
picurl必须是公网可访问的图片地址。我之前一偷懒填了个内网地址,结果消息发出去了,但卡片图片在手机上永远加载不出来。排查半天才发现是图片地址的问题。articles数组最多支持8个元素,但建议一个卡片就主打一个核心信息,别塞太多条,手机端用户体验会好很多。
3.4 群机器人webhook支持的媒体格式
群机器人是“群聊”场景里最轻量的实现方式。webhook地址长这样:
https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx你可以直接在代码里POST一个JSON,往指定群里发消息。举个例子,发一条markdown格式的群消息:
webhook_url = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxxx" markdown_payload = { "msgtype": "markdown", "markdown": { "content": "## 服务上线提醒 \n > 版本由 **v1.2.0** 更新至 **v1.3.0**" } } resp = requests.post(webhook_url, json=markdown_payload) print(resp.json())群机器人支持的格式也不少:文本text、markdown、图片image、图文news、文件file、语音voice等。注意几点:
- 群机器人发图片/文件也需要先传素材拿media_id,但要走
webhook/upload_media这个接口,不是上面提的media/upload。很多人第一次做,把两个接口混用,就会报media_id无效。 - 群里添加机器人时有一个“安全设置”,如果你勾选了“关键词”限制,那消息内容必须包含指定关键词,否则调用会报错。这个坑特别隐蔽,报错信息也不直观,我第一次遇到时查了半天才发现是关键词没写。
- webhook方式不需要access_token,只需要webhook地址本身。所以任何人拿了这个地址都能往群里发消息,地址泄露等于是把群聊的发言权交了出去,要注意保护。
4. 完整实操:从配置应用到发出一条群聊图片
前面讲了不少原理,这一节带大家完整走一遍流程。我按“最可能遇到的真实场景”来串:先是私聊文本,然后私聊图片,最后往群里发markdown消息。
4.1 前置准备
你需要先在企业微信管理后台完成这些操作:
- 注册企业微信,获得
CorpID(企业ID)。 - 在“应用管理-自建”里创建一个自建应用,拿到
AgentId和Secret。 - 在“管理后台-我的企业-可信信息”里,配置你的服务器公网IP为企业可信IP。
- 如果要体验群聊,任意创建一个内部群,在群设置里添加“群机器人”,复制webhook地址。
- 准备一个小型测试文件,比如一张小于2MB的图片
test.png。
这些步骤都是后台点点点,不涉及代码,但一定要做对。尤其是第3步,很多人都忽略了。
4.2 获取并缓存access_token
代码直接复用第2部分写的TokenManager类。如果你用Flask或FastAPI写服务,可以在应用启动时初始化一个全局的TokenManager实例:
token_mgr = TokenManager(corp_id="ww1234567890abcdef", secret="你自己的应用Secret") # 在发送函数里获取token token = token_mgr.get_token()这里再补一个细节:如果你的服务是多进程或多实例部署,用内存缓存会导致每个进程各自维护一个token,有一半请求的token可能是过期的。生产环境建议把token放Redis,key名带上corpid和secret的哈希值,实现全局共享。
4.3 发送纯文本私聊消息
现在开始第一发。假设你要给userid为zhangsan的员工发一条纯文本消息:
send_url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}" payload = { "touser": "zhangsan", "msgtype": "text", "agentid": 1000002, "text": { "content": "你好,你的工单已处理完成。" } } resp = requests.post(send_url, json=payload) data = resp.json() print(data)如果返回结果errcode为0,说明消息已成功进入发送队列。注意,接口返回成功不代表对方已经“已读”,也不代表网络送达,但正常情况下几秒内就会在客户端出现。
这里有个小技巧:接口返回后,如果你能获取到消息的msgid,可以在后续做更多追踪。不过这个接口的返回里通常不包含msgid,别强求。
4.4 上传临时素材并发图片消息
下面来个更复杂的:发一张图片。先上传素材,再发消息:
# 第一步:上传临时素材 upload_url = f"https://qyapi.weixin.qq.com/cgi-bin/media/upload?access_token={token}&type=image" with open("test.png", "rb") as f: resp = requests.post(upload_url, files={"media": f}) data = resp.json() if data["errcode"] != 0: print("上传失败:", data) exit(1) media_id = data["media_id"] print("拿到media_id:", media_id) # 第二步:发送图片消息 send_url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}" image_payload = { "touser": "zhangsan", "msgtype": "image", "agentid": 1000002, "image": { "media_id": media_id } } resp = requests.post(send_url, json=image_payload) print(resp.json())我在实际开发时,会把“上传素材”封装成一个通用函数,内部处理文件大小超限、格式校验、失败重试等逻辑。因为在批量推送的场景里,可能一次要发几十张图,如果每个环节都手写,代码会非常臃肿。封装之后的调用长这样:
def send_image(token, to_user, media_id, agent_id): payload = { "touser": to_user, "msgtype": "image", "agentid": agent_id, "image": {"media_id": media_id} } return requests.post( f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}", json=payload ).json()4.5 通过群机器人往群聊发markdown消息
最后展示怎么往群里发消息。这里不通过access_token,而是直接调webhook:
webhook_url = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxx" payload = { "msgtype": "markdown", "markdown": { "content": "## 监控告警 \n > 线上服务异常率超过5%,请及时处理。\n > 时间点:2026-03-20 10:30:00" } } resp = requests.post(webhook_url, json=payload) print(resp.json())如果群里给机器人配置了“关键词”,markdown内容里必须包含那个关键词。比如你配置的关键词是“告警”,上面这条消息里最好明确带“告警”两个字,否则返回如下错误:
{"errcode":93000,"errmsg":"invalid webhook url, please check the webhook info"}这个报错非常迷惑,看起来像是webhook地址无效,实际是安全设置拦截。我第一次做的时候被它坑了整整一下午。
5. 高频踩坑记录与排错参考
把我在项目里遇到过的所有问题汇总成一张表,你收藏这份,以后调接口遇到报错直接对号入座。
5.1 返回码快速排查表
| 返回码 | 含义 | 常见原因 | 处理建议 |
|---|---|---|---|
| 0 | 成功 | 无 | 无需处理 |
| 40001 | access_token无效 | token过期、缓存被清、重新获取导致旧token失效 | 重新获取token,用新token重试 |
| 40003 | userid不合法 | 用户不在通讯录、不是应用的可见范围 | 检查userid大小写、同步通讯录、扩大应用可见范围 |
| 40013 | corpid不合法 | 配置的corpid写错了 | 去管理后台核对 |
| 40014 | access_token不合法 | token错误或已被覆盖 | 重新获取token |
| 41030 | media_id无效 | 素材类型不匹配、素材过期、用错上传接口 | 重新上传素材,检查type参数 |
| 45009 | 接口调用超过频率限制 | 并发过高、循环调用、token重复获取 | 加本地队列,控制调用频率,批量消息分批发送 |
| 60011 | 无权限访问 | 应用没有通讯录权限、Secret不对应 | 检查应用权限设置,确认Secret与AgentId匹配 |
| 60020 | 访问来源IP不在白名单 | 服务器公网IP未加入可信IP | 去管理后台配置可信IP |
| 93000 | 群机器人消息被拦截 | webhook地址不对、安全设置关键词不匹配 | 检查webhook地址,核对关键词规则 |
5.2 几个典型问题实录
除了返回码,我在项目里实际踩过的几个场景,也值得单独说。
场景一:接口返回成功,但员工就是收不到消息
这是最让我头疼的问题。代码检查了很多遍,返回码始终是0,但员工的企业微信里就是没有新消息。排查到最后发现,这个应用在企业微信后台里从来没被员工“使用”过,而且可见范围也跟员工无关。说白了,你在后台把应用可见范围设成了“研发部”,但给一个“市场部”的人发消息,接口可能不会直接报错,而是默默丢弃。所以收到0也别高兴太早,先确认接收人是否在可见范围里。
场景二:给部门发广播返回60011
我当时想给某个部门发通知,toparty填的是部门ID,但一直返回60011,提示没有权限。原因是应用可见范围只勾选了“全体成员”里的某几个部门,没有单独把这个部门勾进去。有些你以为是后台配置的问题,其实是“可见范围”和“通讯录权限”两个概念混在一起,要同时满足。
场景三:群机器人发图片报media_id无效
前面提过,群机器人上传素材的接口是/webhook/upload_media,但是很多人(包括我)容易下意识直接用/media/upload。这两个接口返回的media_id格式都差不多,但互相不通用。所以如果你确认token没有过期、图片也没超2MB,但报media_id无效,赶紧看看是不是用的上传接口不对。
场景四:文本消息内容里带中文或特殊符号出现乱码
这个问题主要出现在一些老旧的HTTP客户端上,设置请求头的时候没指定UTF-8编码。企业微信接口是标准的UTF-8,所以Content-Type一定要写成application/json; charset=utf-8。requests库默认会处理好,但如果自己用其他语言写SDK,就要注意。
最后分享几点我的个人体会
接口文档写得很全,但是“能跑通”和“在生产环境中稳定跑”之间还隔着大量细节。我在做了好几个类似项目之后,最大的感受是:一定要在项目初期设计一个统一的消息服务层,把token缓存、素材上传、消息发送、重试逻辑全部封装好,不要让业务代码直接裸调接口。这样后面接更多消息类型时,只改服务层就够了,不用动上层业务。
另外,不管你用py、Java还是Go,先做小范围灰度。我的习惯是:先把代码写一个命令行脚本,手动发给自己和测试号,逐项验证文本、图片、文件和群机器人消息类型,全部跑通再接入业务系统。别一上来就做批量推送,否则限流、权限、media_id过期这些问题混在一起,你会被搞得怀疑人生。
企业微信消息接口的价值不在于“能发消息”这个动作,而在于它可以被程序化地整合进你的运维系统、报表系统、客户运营系统,成为整个自动化工作流里最接近人的一环。先把私聊文本跑通,再逐步加上图片、视频、群机器人,你会发现这套API能做的事情,远比想象中要多得多。