Sign inGet Started

发送 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);

客服窗口

你能发送哪种类型取决于一个状态:客服窗口是否处于打开状态。

联系人通过向你的企业号码发送消息或拨打电话来打开窗口,窗口保持 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。设置该字段会返回 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" }
}

字段参考

字段类型必填限制 / 说明
tostring是每条消息一个收件人:E.164 电话号码、业务范围用户 ID(一次性验证码模板不接受此类型)或 WhatsApp 群组 ID(wag_…),后者向群组的每位参与者发送
fromstring (E.164)否**Bird 托管的模板省略此字段,由其自行选择发送号码;群组发送也省略,使用群组自身的号码;服务消息和工作区自建的模板必填,且必须是工作区拥有的号码
template.slugstring否**工作区可发送的模板标识名;Bird 托管的标识名以 bird_ 开头
template.languagestring否*模板语言标签(en、pt-BR);省略则发送模板的默认语言
template.componentsarray否填充模板变量;组件 type 为 body 或 button
template.components[].parameters[].namestring否†此值填充的占位符,例如 ref;命名参数模板必填且必须与模板声明的名称匹配,按位置模板则省略
interactiveobject否**正文文本加上一种可点击内容;属于服务消息,因此需要服务窗口处于打开状态。参见互动消息
in_reply_to_message_idstring否此工作区持有的 WhatsApp 消息 ID,在发送的消息中引用;读取时回显。参见引用消息
tagsarray否最多 20 个 {name, value} 标签;名称 ≤ 32 字符,值 ≤ 64,名称唯一
metadataobject否任意 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_amountBird 处理发送的费用Bird 处理已接受的发送时,在分发之前
passthrough_amountMeta 的消息价格份额,由 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} 读取消息。

后续步骤