发送 WhatsApp 消息
本指南介绍发送端点 POST /v1/whatsapp/messages。你构造一个 JSON 载荷,包含一个收件人和恰好一种内容类型:预审批的模板,或承载文本、图片、视频、音频、贴纸、文档、位置、联系人名片或可点击内容的服务消息。Bird 返回 202 Accepted(附带消息 ID)并异步投递。你能发送哪种类型取决于客服窗口。每个请求向一个收件人发送一条消息,没有批量端点。
最小发送示例
最小的有效载荷包含一个 to 收件人和一个带 slug 的 template。如果需要指定语言,添加 language;省略则使用模板的默认语言。通过 components 填充模板声明的所有变量。
curl 调用指定的是美国主机;如果你的密钥以 bk_eu1_ 开头,请改为调用 https://eu1.platform.bird.com。SDK 会从密钥中读取区域,因此无需设置主机。
const msg = await bird.whatsapp.send({
to: "+15551234567",
template: {
slug: "bird_otp",
components: [{ type: "body", parameters: [{ type: "text", text: "123456" }] }],
},
});
console.log(msg.id, msg.status);msg = client.whatsapp.send(
to="+31612345678",
template="bird_otp",
language="en",
components=[{"type": "body", "parameters": [{"type": "text", "text": "123456"}]}],
)
print(msg.id, msg.status)code := "123456"
msg, err := client.Whatsapp.Send(context.Background(), bird.WhatsappSendParams{
To: "+15551234567",
Template: "bird_otp",
Language: "en",
Components: []bird.WhatsAppMessageTemplateComponent{{
Type: "body",
Parameters: &[]bird.WhatsAppMessageTemplateComponentParameter{{Type: "text", Text: &code}},
}},
})
if err != nil {
log.Fatal(err)
}
fmt.Println(msg.Id, *msg.Status)$message = $bird->whatsapp->send(
to: '+15551234567',
template: 'bird_otp',
language: 'en',
components: [
(new WhatsAppMessageTemplateComponent())
->setType('body')
->setParameters([
(new WhatsAppMessageTemplateComponentParameter())->setType('text')->setText('123456'),
]),
],
);
echo $message->getId(), ' ', $message->getStatus();bird whatsapp send \
--components '[{"parameters":[{"text":"1234","type":"text"}],"type":"body"},{"parameters":[{"text":"1234","type":"text"}],"type":"button"}]' \
--language en \
--template bird_otp \
--to +31612345678curl -X POST "https://{region}.platform.bird.com/v1/whatsapp/messages" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"to": "+31612345678",
"template": {
"slug": "bird_otp",
"language": "en",
"components": [
{
"type": "body",
"parameters": [
{
"type": "text",
"text": "1234"
}
]
},
{
"type": "button",
"parameters": [
{
"type": "text",
"text": "1234"
}
]
}
]
}
}'客服窗口
你能发送哪种类型取决于一个状态:客服窗口是否处于打开状态。
联系人通过向你的企业号码发送消息或拨打电话来打开窗口,窗口保持 24 小时有效,每次联系人再次发送消息时重置。窗口打开期间,你可以发送服务消息,即任意自由格式内容:文本、图片、视频、音频、贴纸、文档、位置或互动消息。窗口关闭后,只有预审批的模板能送达对方,而对方回复模板会重新打开窗口。
Bird 为你跟踪窗口状态,因此向已关闭的窗口发送服务消息会在创建或计费之前被拒绝:请求返回 422 E15044 WhatsAppServiceWindowClosed。该检查为尽力而为且失败时默认放行,因此 202 并不能证明窗口在分发时确实处于打开状态;如果窗口在接受和分发之间关闭,则异步失败,消息的 last_error 上会出现 service_window_expired。
参阅客服窗口了解完整生命周期:什么操作会打开窗口、什么操作会重置窗口,以及窗口如何影响定价。
构建载荷
收件人
to 指定一个目标收件人,可以是电话号码、业务范围用户 ID 或群组 ID。电话号码采用 E.164 格式:以 + 开头,后接国家代码和用户号码,例如 +14155550100。我们会验证该号码,因此无法成为真实可拨号码的值(长度错误、前缀未分配)会在产生任何费用之前被拒绝,返回 422 WhatsAppInvalidRecipient。没有收件人数组,也没有批量发送接口,因此向不在同一群组中的多人发送消息意味着每个收件人一次调用。
企业范围用户 ID(例如 US.13491208655302741918)用于联系你没有其电话号码的用户,适用于回复未提供号码的联系人。有两点不同:发送号码必须属于该 ID 所属的同一企业组合,且一次性验证码模板需要电话号码。Bird 托管的模板会在接受时被拒绝,返回 422 WhatsAppRecipientNotSupportedForTemplate;你的工作区自建的身份验证模板会被接受但随后失败,因为 Meta 要求提供电话号码。
to 还接受另一种形式:WhatsApp 群组 ID,例如 wag_01krdgeqcxet5s7t44vh8rt9mg,会向该群聊的每位参与者发送消息。群组发送省略 from,并按群组整体而非单个收件人报告投递状态,因此向 WhatsApp 群组发送在单独的页面中介绍。
模板
template 指定要发送的预审批模板:
slug(必填):模板的标识名,例如bird_order_confirmation。必须与目录中的某个模板匹配(小写字母、数字和下划线)。language:模板的语言标签,例如en或pt-BR。省略则发送模板的默认语言;指定模板不支持的语言会返回422,并列出支持的语言。已接受的消息会回显解析后的语言。components:填充模板变量的值(参见组件与参数)。如果模板没有变量则省略。
在模板页面浏览您的模板、它们的语言,以及每个模板的渲染预览。
组件与参数
模板包含变量,可以是命名的({{ref}}、{{amount}})或按位置编号的({{1}}、{{2}})。通过 components 提供变量值。每个组件指定一个 type(body 或 button)和一个 parameters 数组。每个参数指定自身的 type(text、image、video、gif、document 或 location)并携带对应的字段:text 为纯文本字符串,image/video/gif/document 为公开的 https url,location 为地图上的一个坐标点。使用命名参数的模板要求每个参数带有 name,且必须与模板声明的名称完全匹配(参见字段参考)。按位置的模板省略 name,按 {{n}} 顺序获取值,因此第一个参数填充 {{1}}。无论哪种方式,与模板声明不匹配的参数会返回 422 WhatsAppTemplateParameterMismatch。线路上还存在 header 组件类型:在 Bird 托管的模板上会被丢弃,因为 Bird 托管的模板不声明头部变量;但在工作区自建的模板上会被转发,这就是媒体头部的 utility 或 marketing 模板获取图片的方式。
例如,一个一次性验证码模板,其正文内容为 {{1}} is your verification code,其按钮会复制验证码,那么验证码需要同时作为正文参数和按钮参数、按位置传入(无 name):
{
"components": [
{ "type": "body", "parameters": [{ "type": "text", "text": "481920" }] },
{ "type": "button", "parameters": [{ "type": "text", "text": "481920" }] }
]
}类别与发送号码
模板的类别(authentication、utility 或 marketing)决定 WhatsApp 如何处理消息,并与目的地国家共同决定费用。
发送号码的归属方决定您是否需要指定它:
- Bird 托管的模板(其标识名以
bird_开头)使用 Bird 为该类别保留的号码发送,因此省略from。设置该字段会返回422WhatsAppSenderNotAllowed。 - 其他所有情况在
from中指定自己的发送号码:任何类型的服务消息,以及工作区自建的任何模板。该号码必须归您的工作区所有。省略会返回422WhatsAppSenderRequired,工作区无权使用的号码会返回422WhatsAppSenderNotFound。自建模板还必须与该号码位于同一 WhatsApp Business Account 下,否则发送会返回422WhatsAppSenderWABAMismatch。
电话号码设置介绍了两种类型的号码以及如何接入您自己的号码。
服务消息
不使用 template 时,携带以下之一:text、image、video、audio、sticker、document、location、contact_cards 或 interactive。这九种都是服务消息,因此需要客户服务窗口处于打开状态。它们都需要 from,即工作区拥有的号码;Bird 的托管号码无法用于发送服务消息。
text:{ "body": "..." },最多 4096 个字符。添加"preview_url": true可为body中的第一个 URL 渲染链接预览。image、video、audio、sticker、document:每种都接受一个公开的httpsURL,WhatsApp 在发送时获取该文件(url),因此签名 URL 的有效期必须长于发送时间。httpURL 会被直接拒绝。WhatsApp 获取文件本身,因此无法访问的 URL、提供不支持类型的 URL 或超出该类型大小限制的文件会被接受但随后失败,消息的last_error上出现media_rejected,WhatsApp 自身的原因记录在description中。image、video和document还接受可选的caption;document还接受可选的filename;audio接受可选的voice标志以进行语音备忘录渲染。location:{ "latitude": ..., "longitude": ... }(均为必填,十进制度数)加上可选的name和address。contact_cards:一条消息中最多共享五个联系人的数组。每张卡片的name需要formatted_name以及至少一个其他部分(first_name、last_name、middle_name、prefix或suffix);phone_numbers、emails、urls和addresses各最多接受十个条目,org和birthday(作为YYYY-MM-DD)为可选。E.164 格式的phone_number会为该卡片生成一个打开聊天的按钮。interactive:正文文本加上一个可点击的元素,共六种类型:回复按钮、列表菜单、链接按钮、媒体轮播,或一个请求收件人提供位置或电话号码的单按钮。互动消息介绍了每种类型的线路格式、点击产生的回复以及相关限制。
{
"to": "+16505551234",
"from": "+13124495648",
"text": { "body": "Your order shipped: https://example.com/track/A1B2C3", "preview_url": true }
}不携带任何内容或携带多种类型的请求会被拒绝,返回 422。
引用消息
将 in_reply_to_message_id 设置为一个 WhatsApp 消息 ID,即可将您的消息作为对该消息的回复发送,效果与在 WhatsApp 应用中点击回复引用消息相同。收件人会看到您的消息上方显示被引用的消息,该字段在每次读取消息时都会返回。
反过来也一样:WhatsApp 标记为回复的入站消息会在同一字段中携带被引用消息的 ID,您可以据此判断回复的是您的哪条消息。WhatsApp 未标记的入站消息不携带 ID,解析也可能失败。要实现可靠的关联,请使用显式的互动回复标识符配合应用自身存储的对话或任务状态。出站的 metadata 保留在出站记录上,不会自动复制到回复中。
引用在发送被接受之前解析,因此无法渲染的引用会直接导致请求失败,不会创建或计费任何内容。指定的 ID 不属于此工作区持有的任何消息,或超过消息可被引用的 15 天期限,会返回 404 E15071 WhatsAppReferencedMessageNotFound。指定的消息从未到达 WhatsApp,或与此次发送的 to 和 from 不在同一对话中,会返回 422 E15072 WhatsAppMessageNotQuotable。如果 Bird 无法访问用于解答的存储服务,发送会返回 503 E15073 WhatsAppMessageLookupUnavailable,值得重试。引用在模板发送和自由格式发送上均可使用。
{
"to": "+16505551234",
"from": "+13124495648",
"in_reply_to_message_id": "wam_01kya19eknftrs2s6p82asmvnh",
"text": { "body": "Yes, that slot is still free." }
}标签与元数据
两个可选字段可将您自己的上下文附加到消息上;两者都会在 API 读取时返回,并随该消息的每个 webhook 事件 一起传递:
tags:最多 20 个结构化的{ "name": ..., "value": ... }标签,用于可筛选和报告的低基数维度(例如活动、实验变体)。名称和值接受 ASCII 字母、数字、下划线和连字符;名称最长 32 个字符且在单次发送中唯一,值最长 64 个字符。按标签筛选消息列表(?tag=campaign或?tag=campaign:launch-week),指标页面按标签细分投递数据。metadata:一个任意的 JSON 对象,序列化后最大 2 KB,用于不需要作为筛选维度的单次发送上下文(例如内部订单 ID、会话引用)。
{
"tags": [{ "name": "campaign", "value": "order-confirmations" }],
"metadata": { "order_id": "ord_8271" }
}字段参考
| 字段 | 类型 | 必填 | 限制 / 说明 |
|---|---|---|---|
to | string | 是 | 每条消息一个收件人:E.164 电话号码、业务范围用户 ID(一次性验证码模板不接受此类型)或 WhatsApp 群组 ID(wag_…),后者向群组的每位参与者发送 |
from | string (E.164) | 否** | Bird 托管的模板省略此字段,由其自行选择发送号码;群组发送也省略,使用群组自身的号码;服务消息和工作区自建的模板必填,且必须是工作区拥有的号码 |
template.slug | string | 否** | 工作区可发送的模板标识名;Bird 托管的标识名以 bird_ 开头 |
template.language | string | 否* | 模板语言标签(en、pt-BR);省略则发送模板的默认语言 |
template.components | array | 否 | 填充模板变量;组件 type 为 body 或 button |
template.components[].parameters[].name | string | 否† | 此值填充的占位符,例如 ref;命名参数模板必填且必须与模板声明的名称匹配,按位置模板则省略 |
interactive | object | 否** | 正文文本加上一种可点击内容;属于服务消息,因此需要服务窗口处于打开状态。参见互动消息 |
in_reply_to_message_id | string | 否 | 此工作区持有的 WhatsApp 消息 ID,在发送的消息中引用;读取时回显。参见引用消息 |
tags | array | 否 | 最多 20 个 {name, value} 标签;名称 ≤ 32 字符,值 ≤ 64,名称唯一 |
metadata | object | 否 | 任意 JSON,序列化后最大 2 KB |
* language 为可选;省略则发送模板的默认语言。
† name 在命名参数模板中每个参数都必填。按位置模板则省略。参见组件与参数。
** 只能携带 template 或一种服务消息内容字段(text、image、video、audio、sticker、document、location、interactive)中的一个;参见服务消息。
异步模型:202 的含义
发送成功时返回 202 Accepted,包含消息 ID 和 status: accepted。只有在发送被持久化接受后才会返回 202;消息不会在被接受后被静默丢弃。可修复的硬性失败会立即返回 422:无效的收件人、未知的模板标识或语言、参数不匹配,或在客户服务窗口关闭时发送服务消息(WhatsAppServiceWindowClosed)。钱包余额不足不属于此类:发送仍会被接受,当 Bird 尝试扣费时消息最终变为 rejected 并附带 insufficient_balance。实际投递异步进行:消息在我们将其交给 WhatsApp 时变为 sent,然后在回执到达时变为终态(delivered 或 failed),通过 events、webhooks 和读取端点报告。已读回执作为 read_at 时间戳和 whatsapp.read 事件单独呈现,而非作为状态。
一条隐私说明:对于 authentication 类别的模板,API 不会返回已填充的值。202 回显和之后的每次读取都会为这些消息携带一个空的 components 数组,因此验证码不会再次出现。
安全重试
在每次逻辑发送时使用唯一值发送 Idempotency-Key 请求头,重试就变得安全。如果您的第一次请求成功但未收到响应(超时、连接断开),使用相同的键重放请求会返回原始结果,而不会发送和计费重复消息。重放的响应会携带 Idempotency-Replay 请求头。参见幂等性了解键的格式和保留期限。
接收回复
入站消息与出站消息位于同一资源上,每条入站消息都会重置服务窗口。接收 WhatsApp 消息介绍了通过 API 读取入站消息、获取联系人发送的媒体,以及 whatsapp.received webhook。
费用与计费
WhatsApp 按消息计费,取决于模板类别和收件人所在国家;参见 WhatsApp 定价。一条消息分两步、在两个不同时刻计费,消息上的 cost 对象报告两者:
| 字段 | 含义 | 何时产生 |
|---|---|---|
transaction_amount | Bird 处理发送的费用 | Bird 处理已接受的发送时,在分发之前 |
passthrough_amount | Meta 的消息价格份额,由 Bird 透传 | 当适用的 delivered 或 read 回执到达时 |
amount | 已定价组件的累计总和 | 随每个组件产生而增长 |
currency_code | 您组织钱包的货币,两个组件共用 | 随第一个组件产生 |
两个金额均为十进制字符串,不含税。
Note: a service message carries no Meta share until September 30th 2026, and neither does a utility template delivered inside an open customer service window. From October 1st 2026 Meta charges for both, except inside a 72-hour free entry point window, and with 1,000 free service messages per business phone number per month: October 2026 pricing changes.
两个组件基于不同的输入定价。Bird 的费用使用您发送的模板类别和收件人所在国家,国家信息来自电话号码的国家代码,或在发送给业务范围用户 ID 时来自该 ID 的两字母前缀。Meta 的份额使用 Meta 自身在适用回执上报告的类别,这可能与模板的类别不同:当 Meta 的目的地、商家位置和资格规则适用时,Meta 可能报告 authentication-international。参见 WhatsApp authentication-international 费率。
cost 读取到的值取决于消息传输到了哪个阶段:
- 在
202时,cost为 null。尚未定价。 - 处理之后,
transaction_amount已设置且amount与其相等。passthrough_amount仍为 null。 - 在适用的
delivered或read回执之后,成功记录的 Meta 费用填充passthrough_amount,amount反映已记录的组件。
null 组件表示该投影中未记录金额,并非消息免费的证据。显式定价为零的组件读取为 "0.00000"。
两种扣费的失败方式也不同。Bird 的费用采用闭合式失败:当 202 之后因钱包余额不足或路由未配置价格而无法完成扣费时,消息最终变为 rejected 并携带错误码 insufficient_balance 或 price_not_found,不会产生任何费用。rejected 状态的消息从未到达 WhatsApp,这正是它与 failed 的区别。Meta 的份额采用开放式失败:如果回执到达时钱包余额不足或费率缺失,该扣费会被跳过,不会撤销已观察到的消息状态。您的投递永远不会因第二笔费用而被阻塞。
已被 Bird 扣费的消息即使后续投递失败也会保留该出站费用。Meta 的费用在 Meta 报告带有可解析类别和目的地的常规定价时,从适用的 delivered 或 read 回调中处理。两条回调路径使用相同的费用标识,并依赖计费服务的去重。请将重放的回执与计费记录核对,而不是将消息投影视为永久借记凭证。服务定价或免费入口定价可以使 Meta 组件为零;未解析的组件并非消息免费的证据。
请使用计费账本进行财务对账。消息 cost 字段是费用的投影,可能存在延迟或不完整。参见 WhatsApp 指标了解消息观测值与计费记录之间的区别。
WhatsApp 事件不携带费用。要读取任一组件,请通过 GET /v1/whatsapp/messages/{id} 读取消息。
后续步骤
- 服务消息:在客户服务窗口内发送文本、媒体和互动内容
- 模板:选择模板并填充其变量
- 接收 WhatsApp 消息:读取回复并获取入站媒体
- 消息状态 Webhook:接收投递和失败更新
- 幂等性:使用
Idempotency-Key请求头安全地重试