发送 WhatsApp 消息
本指南介绍发送端点 POST /v1/whatsapp/messages。你只需构建一个 JSON 载荷,其中包含一个收件人和恰好一种内容:预先审核通过的模板,或服务消息。服务消息可以携带文本、图片、视频、音频、贴纸、文档、位置、联系人卡片或可点按的内容。Bird 返回带有消息 ID 的 202 Accepted,并异步投递消息。你能发送哪一种,取决于客户服务窗口。每个请求只向一个收件人发送一条消息,不提供批量端点。
最简发送
最小的有效载荷包含一个 to 收件人,以及带有 slug 的 template。如需指定语言,请添加 language;省略时发送模板的默认语言。模板声明的变量通过 parameters 填充。
curl 示例使用美国主机;如果你的密钥以 bk_eu1_ 开头,请改为调用 https://eu1.platform.bird.com。SDK 会从密钥中读取区域,因此无需设置主机。
const msg = await bird.whatsapp.send({
to: "+15551234567",
template: {
slug: "bird_otp",
parameters: { code: "123456" },
},
});
console.log(msg.id, msg.status);msg = client.whatsapp.send(
to="+15551234567",
template="bird_otp",
language="en",
parameters={"code": "123456"},
)
print(msg.id, msg.status)msg, err := client.Whatsapp.Send(context.Background(), bird.WhatsappSendParams{
To: "+15551234567",
Template: "bird_otp",
Language: "en",
Parameters: map[string]any{"code": "123456"},
})
if err != nil {
log.Fatal(err)
}
fmt.Println(msg.Id, *msg.Status)$message = $bird->whatsapp->send(
to: '+15551234567',
template: 'bird_otp',
language: 'en',
parameters: ['code' => '123456'],
);
echo $message->getId(), ' ', $message->getStatus();bird whatsapp send \
--language en \
--parameters '{"code":"123456"}' \
--template bird_otp \
--to +15551234567curl -X POST "https://{region}.platform.bird.com/v1/whatsapp/messages" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"to": "+15551234567",
"template": {
"slug": "bird_otp",
"language": "en",
"parameters": {
"code": "123456"
}
}
}'客户服务窗口
你能发送哪一种消息,取决于一个状态:客户服务窗口是否处于打开状态。
联系人向你的企业号码发送消息或拨打电话时,窗口即打开,并保持 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(必填):模板的 slug,例如bird_order_confirmation。它必须与你目录中的某个模板匹配(小写字母、数字和下划线)。language:模板的语言标签,例如en或pt-BR。省略时发送模板的默认语言;指定模板不具备的语言会返回422,其中列出模板支持的语言。受理后的消息会回显最终确定的语言。parameters:填充模板变量的值,以按名称作为键的单个对象提供(参见组件和参数)。模板没有变量时可省略。components:以 Meta 组件结构提供的相同值,用于替代parameters。少数模板只接受这种形式。
在模板页面浏览你的模板、模板语言以及每个模板的渲染预览。
组件和参数
模板包含变量,变量可以是命名变量({{ref}}、{{amount}}),也可以是编号变量({{1}}、{{2}})。通过 parameters 这一个对象填充这些变量。模板版本的 variables 列出它接受的键,并标明哪些是可选的。每个键属于以下之一:
- 命名变量自身的名称,例如
order_id - 编号变量的编号,例如
1 - 媒体或位置标题使用
header_url、header_filename或header_location,轮播卡片的标题使用header_url_1、header_url_2等 - URL 按钮的编号后缀使用
button_url,轮播卡片的按钮使用button_url_1、button_url_2等。命名后缀使用其自身的名称。 - 身份验证模板的验证码以及复制验证码按钮的值使用
code - 该语言的参数绑定所读取的输入
每个值都是字符串、数字或布尔值。header_location 接受一个包含 latitude 和 longitude 的对象。省略媒体标题时,将发送模板已获批准的示例。从 Meta 导入的模板没有示例,因此需要提供媒体标题的键。该语言未使用的键会被忽略。
{
"to": "+15551234567",
"from": "+13124495648",
"template": {
"slug": "order_update",
"language": "en",
"parameters": { "order_id": "A-4192", "first_name": "Ava" }
}
}在 CLI 上,通过 --parameters 传入该对象:
bird whatsapp send --to +15551234567 --from +13124495648 \
--template order_update --language en \
--parameters '{"order_id":"A-4192","first_name":"Ava"}'如果身份验证模板的正文为 {{1}} is your verification code,且其按钮用于复制验证码,则只需以 code 传入一次验证码。Bird 会用它同时填充正文和按钮:
{
"parameters": { "code": "481920" }
}如果某个语言的绑定读取联系人数据(例如 bird.contact.first_name),则无法作为单条消息发送,并会返回 E15091。请改为通过群发发送,由每个收件人的联系人数据进行填充。如果收件人的联系人数据导致某个必填值为空,群发会跳过该收件人。请发送 parameters 或 components,不要同时发送两者。有些模板只接受 parameters:一类是其语言通过参数绑定计算值的模板,另一类是在轮播卡片或按钮 URL 中使用命名变量的模板。
有些模板只接受 components,因为单个键无法区分它们的槽位。当模板在多个区块(例如页眉和正文)中都使用 {{1}} 编号时,就会出现这种情况。当一个区块中有两个带编号后缀的 URL 按钮,或者 {{button_url}} 变量与带编号的 URL 按钮并存时,也会出现这种情况。用 parameters 发送此类模板会返回 422。
按组件填充模板。 components 是 parameters 的替代方式,也是单个键无法寻址的模板唯一可用的方式。每个组件指定一个 type(header、body、button 或 carousel)和一个 parameters 数组。carousel 则改为接收 cards 数组,每张已批准的卡片对应一个条目,按批准顺序排列。每张卡片都有自己的 components 数组,其中包含 header、body 和 button 条目。每个参数指定自己的 type(text、image、video、gif、document 或 location),并携带对应字段:text 为纯字符串,image/video/gif/document 为公开的 https url,location 为地图上的一个点。使用命名参数的模板要求每个参数都带有 name,且必须与模板声明的名称完全一致(参见字段参考)。位置参数模板省略 name,改为按 {{n}} 顺序接收值,因此第一个参数填充 {{1}}。无论哪种方式,与模板声明不匹配的参数都会返回 422 WhatsAppTemplateParameterMismatch。在 Bird 托管的模板上,header 组件会被丢弃,因为 Bird 托管的模板都不声明页眉变量;但在你的工作区自行创建的模板上,该组件会被转发,带媒体页眉的实用类或营销类模板正是通过这种方式获得图片。
同一个验证码模板以位置参数方式(不带 name)同时将验证码作为正文参数和按钮参数接收:
{
"components": [
{ "type": "body", "parameters": [{ "type": "text", "text": "481920" }] },
{ "type": "button", "parameters": [{ "type": "text", "text": "481920" }] }
]
}类别与发送方
模板的类别(authentication、utility 或 marketing)决定 WhatsApp 如何处理该消息,并与目标国家共同决定消息费用。
是否需要指定发送方,取决于发送方归谁所有:
- Bird 托管的模板(其 slug 以
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),因此签名 URL 的有效期必须长于发送过程。httpURL 会被直接拒绝。WhatsApp 自行获取文件,因此如果 URL 无法访问、返回不受支持的类型,或文件超过该类型的大小上限,请求会先被接受,随后失败:消息的last_error上为media_rejected,description中为 WhatsApp 自己给出的原因。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),Metrics 页面会按标签细分投递情况。metadata:一个任意的 JSON 对象,序列化后最多 2 KB,用于存放无需作为筛选维度的单次发送上下文(例如内部订单 ID、会话引用)。reference:一个 1 到 255 个字符的字符串,用于查找该消息,例如订单号或工单号。消息列表上的?reference=会对其进行区分大小写的精确匹配,多条消息可以共用同一个值。仅在需要按它搜索时才设置,其他数据请放在metadata中。
{
"tags": [{ "name": "campaign", "value": "order-confirmations" }],
"metadata": { "order_id": "ord_8271" },
"reference": "ord_8271"
}字段参考
| 字段 | 类型 | 必填 | 限制 / 说明 |
|---|---|---|---|
to | string | 是 | 每条消息一个收件人:E.164 格式的电话号码、企业范围用户 ID(一次性验证码模板均不接受),或 WhatsApp 群组 ID(wag_…),后者会发送给该群聊的所有成员 |
from | 字符串 (E.164) | 否** | 使用 Bird 托管模板时省略,此类模板会自行选择发送方号码;群组发送时也省略,此时使用群组自己的号码。发送服务消息和使用工作区自建模板时必填,且必须是你的工作区拥有的号码 |
template.slug | 字符串 | 否** | 你的工作区可发送的模板 slug;Bird 托管模板的 slug 以 bird_ 开头 |
template.language | 字符串 | 否* | 模板语言标签(en、pt-BR);省略时发送模板的默认语言 |
template.parameters | 对象 | 否 | 按名称填充模板的变量,例如 ref 或 code;最大 16 KB,bird 为保留键 |
template.components | 数组 | 否 | parameters 的替代方式,每个组件一个条目:type 为 header、body、button 或 carousel,carousel 接收 cards 而非 parameters |
template.components[].parameters[].name | 字符串 | 否† | 此值填充的占位符,例如 ref;对于命名参数模板为必填,且必须与模板声明的名称一致;对于位置参数模板则省略 |
text | 对象 | 否** | {body, preview_url?} 纯文本服务消息(最多 4,096 个字符);参见纯文本 |
image, video, audio, sticker, document | 对象 | 否** | 带有 url 的媒体服务消息(在支持的类型上可选 caption 或 filename);参见服务消息 |
location | 对象 | 否** | {latitude, longitude, name?, address?} 位置标记;参见位置 |
contact_cards | 数组 | 否** | 最多 5 个结构化 vCard 联系人对象;参见联系人名片 |
interactive | 对象 | 否** | 正文文本加一种可点按内容;属于服务消息,因此需要服务窗口处于开启状态。参见交互式消息 |
in_reply_to_message_id | 字符串 | 否 | 此工作区保存的 WhatsApp 消息 ID,会在你发送的消息中被引用;读取时原样返回。参见引用消息 |
tags | 数组 | 否 | 最多 20 个 {name, value} 标签;名称 ≤ 32 个字符,值 ≤ 64 个字符,名称不可重复 |
reference | 字符串 | 否 | 1–255 个字符;在消息列表中按 ?reference= 精确匹配 |
metadata | 对象 | 否 | 任意 JSON,序列化后最多 2 KB |
* language 为可选;省略时发送模板的默认语言。
† 对于命名参数模板,每个参数都必须提供 name。位置参数模板则省略该字段。参见组件和参数。
** 必须且只能携带 template 或一个服务消息内容字段(text、image、video、audio、sticker、document、location、contact_cards、interactive)中的一个;参见服务消息。
异步模型:202 的含义
发送成功时返回 202 Accepted,其中包含消息 ID 和 status: accepted。只有在发送被持久接受后才会返回 202;已接受的发送绝不会被静默丢弃。可修复的硬性错误会立即以 422 失败:无效的收件人、未知的模板 slug 或语言、参数不匹配,或者在客户服务窗口已关闭时发送服务消息(WhatsAppServiceWindowClosed)。钱包余额不足不属于此类:发送会被接受,当 Bird 尝试扣费时,消息以 rejected 结束,并带有 insufficient_balance。实际投递是异步进行的:我们将消息交给 WhatsApp 时,消息变为 sent;收到回执后,消息进入终态(delivered 或 failed),并通过事件、webhook 和读取端点报告。已读回执不作为状态呈现,而是单独以 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 | 你的组织钱包所用的币种,两部分费用共用 | 随第一部分费用一起出现 |
两项金额均为十进制字符串,不含税。
注意:在 2026 年 9 月 30 日之前,服务消息不产生 Meta 费用,在开放的客户服务窗口内送达的 utility 模板也不产生。自 2026 年 10 月 1 日起,Meta 对这两者均收费,但 72 小时免费入口窗口内的消息除外,且每个企业电话号码每月有 1,000 条免费服务消息:2026 年 10 月定价变更。
这两部分费用的计价依据不同。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 之后,如果钱包余额不足以支付此次发送,或该路由没有配置价格,导致扣费无法完成,消息会以错误代码 insufficient_balance 或 price_not_found 结束于 rejected,且不收取任何费用。rejected 的消息从未到达 WhatsApp,这是它与 failed 的区别。Meta 的费用采用失败即放行:如果回执到达时钱包余额不足或缺少费率,则跳过该笔扣费,已观察到的消息状态不会回退。第二笔扣费绝不会延误你的消息送达。
已由 Bird 扣费的消息,即使之后送达失败,该笔出站费用也会保留。当 Meta 报告常规定价,且类别和目的地均可解析时,Meta 费用会根据相应的 delivered 或 read 回调进行处理。两条回调路径使用相同的费用标识,并依赖计费服务的去重机制。请将重放的回执与计费记录进行对账,不要把消息投影当作永久的扣款凭证。服务定价或免费入口定价可能使 Meta 部分为零;某部分未解析并不能证明消息是免费的。
财务对账请使用计费账本。消息的 cost 字段是费用的投影,可能滞后或不完整。消息观测数据与计费记录的区别,请参阅 WhatsApp 指标。
WhatsApp 事件不包含费用信息。如需读取任一部分费用,请使用 GET /v1/whatsapp/messages/{id} 重新读取该消息。
后续步骤
- 服务消息:在客户服务窗口内发送文本、媒体和交互内容
- 模板:选择模板并提供其变量
- 接收 WhatsApp 消息:读取回复并获取入站媒体
- 消息状态 webhook:接收送达和失败更新
- 幂等性:使用
Idempotency-Key请求头安全地重试