发送 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。电话号码使用 E.164 格式:以 + 开头,接国家代码和用户号码,例如 +14155550100。我们会验证号码,因此不可能是真实可拨号码的值(长度错误、前缀未分配)会在计费之前被拒绝,返回 422 WhatsAppInvalidRecipient。一条消息发送给一个收件人;没有收件人数组也没有批量发送,因此向多人发送时需要每个收件人一次调用。
企业范围用户 ID(例如 US.13491208655302741918)用于联系你没有其电话号码的用户,适用于回复未提供号码的联系人。有两点不同:发送号码必须属于该 ID 所属的同一企业组合,且一次性验证码模板需要电话号码。Bird 托管的模板会在接受时被拒绝,返回 422 WhatsAppRecipientNotSupportedForTemplate;你的工作区自建的身份验证模板会被接受但随后失败,因为 Meta 要求提供电话号码。
模板
template 指定要发送的预审批模板:
- slug(必填):模板的 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 托管的模板声明头部变量;但在你的工作区自建的模板上会被转发,这就是媒体头部工具型或营销型模板获取图片的方式。
例如,一个一次性验证码模板,正文内容为 {{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 托管的模板(其 slug 以 bird_ 开头)使用 Bird 为该类别保留的号码发送,因此省略 from。设置该字段会返回 422 WhatsAppSenderNotAllowed。
- 其他所有情况在 from 中指定自己的发送方:任何类型的服务消息,以及你的工作区自建的任何模板。号码必须是你的工作区拥有的号码。省略会返回 422 WhatsAppSenderRequired,工作区无权使用的号码会返回 422 WhatsAppSenderNotFound。自建模板还必须与号码属于同一个 WhatsApp Business Account,否则发送会返回 422 WhatsAppSenderWABAMismatch。
电话号码设置涵盖两种号码类型以及如何接入你自己的号码。
服务消息
不使用 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:各接受一个公开的 https URL,WhatsApp 在发送时拉取(url),因此签名 URL 的有效期必须长于发送耗时。http URL 会被直接拒绝。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(一次性验证码模板不接受后者) |
| from | string (E.164) | 否** | Bird 托管的模板省略此字段,它会自行选择发送方;服务消息和你的工作区自建的模板必填,且号码必须是你的工作区拥有的 |
| template.slug | string | 否** | 你的工作区可发送的模板 slug;Bird 托管的 slug 以 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:无效收件人、未知模板 slug 或语言、参数不匹配,或向已关闭的客服窗口发送服务消息(WhatsAppServiceWindowClosed)。钱包余额不足不在其中:发送被接受,当 Bird 尝试扣费时消息最终变为 rejected,错误码为 insufficient_balance。实际投递异步进行:消息在交付给 WhatsApp 时变为 sent,然后在回执到达时变为终态(delivered 或 failed),通过 events、webhooks 和读取端点报告。已读回执作为 read_at 时间戳和 whatsapp.read 事件单独呈现,而非作为状态。
一条隐私说明:对于 authentication 类别的模板,API 不会返回填充后的值。202 回显和之后的每次读取都会为这些消息携带空的 components 数组,因此验证码不会再次暴露。
安全重试
发送时在 Idempotency-Key 请求头中携带每次逻辑发送的唯一值,重试即可安全执行。如果你的首次请求已成功但你未收到响应(超时、连接中断),使用相同 key 重放请求会返回原始结果,而不会发送并收费一条重复消息。重放的响应携带 Idempotency-Replay 请求头。参阅幂等性了解 key 格式和保留期限。
接收回复
入站消息与出站消息位于同一资源上,每条入站消息都会重置服务窗口。接收 WhatsApp 消息涵盖通过 API 读取入站消息、获取联系人发送的媒体文件,以及 whatsapp.received webhook。
费用和计费
WhatsApp 按消息计费,基于模板类别和收件人所在国家;参阅 WhatsApp 定价。消息分两步在不同时间点计费,消息上的 cost 对象报告这两部分:
| 字段 | 含义 | 产生时间 |
|---|---|---|
| transaction_amount | Bird 处理发送的费用 | Bird 处理已接受的发送时,分发之前 |
| passthrough_amount | Meta 的消息费用份额,由 Bird 透传 | 当适用的 delivered 或 read 回执到达时 |
| amount | 已计价组件的金额总和 | 随每个组件到达而增长 |
| currency_code | 你的组织钱包的币种,两个组件共用 | 随第一个组件一起出现 |
两个金额均为十进制字符串,不含税。
两个组件基于不同的输入定价。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 消息:入站消息、媒体文件和 whatsapp.received webhook
- 企业范围用户 ID:联系未提供电话号码的联系人
- 模板:浏览目录并查看模板的变量
- 幂等性:使用 Idempotency-Key 请求头安全重试