Sign inGet Started

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

客户服务窗口

你能发送哪一种消息,取决于一个状态:客户服务窗口是否处于打开状态。

联系人向你的企业号码发送消息或拨打电话时,窗口即打开,并保持 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。设置该字段会返回 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),因此签名 URL 的有效期必须长于发送过程。http URL 会被直接拒绝。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"
}

字段参考

字段类型必填限制 / 说明
tostring是每条消息一个收件人: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_amountBird 处理此次发送收取的费用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} 重新读取该消息。

后续步骤