从你自己的系统发送模板

一次 API 调用,把一个已批准的 WhatsApp 模板发给一个号码 —— 从 webhook、定时任务或脚本发出。密钥、模板 ID、请求、每一种拒绝,以及设计时要考虑的两件事。

更新于

有些模板根本不是人发的。潮汐洗衣店希望预约系统一确认时段,取件确认就立刻发出去 —— 不该有人为了这个去打开收件箱。为此有一个端点:

POST https://api.omniocto.com/api/wa-templates/{id}/send

它把一个已批准的模板发给一个电话号码。从 webhook 处理程序或脚本里调用它 —— “订单发出时,发送取件模板”。因为是模板,24 小时窗口不适用 (原因)。

获取 API 密钥

密钥在 /settings/api-keys —— 齿轮菜单里的 管理与系统 → API 密钥。

  1. 点击 创建密钥。对话框 创建 API 密钥 会要你填一个 名称 (可选) —— 比如 “例如 CI 流水线”;用将要使用它的系统来命名。
  2. API 密钥已创建 只显示一次密钥,并提示 “请立即复制此密钥 — 之后将无法再次 查看。” 把它存到你的系统存放密钥的地方。

列表会显示每个密钥的 上次使用 日期,撤销密钥 会立即停用它:“任何使用它的 集成都会失败。” 每个集成用一个密钥,这样撤销才安全。

每次调用都以 Authorization: Bearer <key> 的形式带上密钥。

找到模板 ID

打开 /templates/whatsapp,点击模板。抽屉面板打开,页面地址会多出 &template=wt-… —— 这个 wt- 短 ID 就是要放进路径里的。(模板的 UUID 也可以。)

同一个面板会告诉你这次调用需要填什么:

  • 变量 —— 正文的变量:要么是 “2 个位置变量”({{1}}、{{2}}),要么是 名称: 后面跟着变量名。
  • 页眉 —— 它的文字,或者媒体页眉对应的 [IMAGE]、[VIDEO] 或 [DOCUMENT]。
  • 按钮 —— 按顺序列出,每个都带类型([URL]、[COPY_CODE]、[QUICK_REPLY])。 第一个的索引是 0。

在脚本里,GET /api/wa-templates?status=approved 会列出已批准的模板,带 shortId、name、language 和 category。

只有已批准的模板才能发送;其他任何状态都会被拒绝。

请求

请求体有两个字段:

字段含义
to电话号码,E.164 格式 —— +14255550123
componentsMeta 自己的 template.components 数组,原样传递。没有变量的模板可以省略

components 就是你发给 Meta Cloud API 的那个数组,所以 Meta 关于它的文档同样适用。 简单说:

  • 页眉 —— 一个参数:带变量的文本页眉用 text,或者用带 https:// link 的 image、video 或 document。
  • 正文 —— text 参数。按位置的模板({{1}}、{{2}})按顺序发送,不带名字。 命名模板({{customer_name}})要给每个参数加上 parameter_name。两种写法不要 混用。Octo 创建的模板在这里是按位置的,不管你当初给变量起了什么名字。
  • 按钮 —— 每个需要取值的按钮一个组件,带 sub_type 和 index(它在模板按钮 列表里的位置):
    • url —— 一个 text 参数:链接里变化的那部分。
    • copy_code —— 验证码,可以用 Meta 的 { "type": "coupon_code", "coupon_code": "FRESH20" } 形式,也可以用 { "type": "text" }。最多 15 个字符。
    • quick_reply —— 可选;如果你希望按钮被点击时收到一个 payload,就加一个 { "type": "payload" } 参数。

完整示例

潮汐洗衣店的取件模板 pickup_confirmation,正文是 Hi {{1}}, your pickup is confirmed for {{2}}. Reply here to change it.,还有一个 Track pickup 按钮,链接以订单号结尾:

curl -X POST "https://api.omniocto.com/api/wa-templates/wt-a1b2c3d4e5/send" \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+14255550123",
    "components": [
      { "type": "body", "parameters": [
        { "type": "text", "text": "Ana" },
        { "type": "text", "text": "Tuesday, 9–11am" }
      ] },
      { "type": "button", "sub_type": "url", "index": "0",
        "parameters": [{ "type": "text", "text": "TW-20931" }] }
    ]
  }'

如果这个模板用的是命名变量,正文会给每个值加上名字:

{ "type": "body", "parameters": [
  { "type": "text", "parameter_name": "customer_name", "text": "Ana" },
  { "type": "text", "parameter_name": "pickup_slot", "text": "Tuesday, 9–11am" }
] }

成功返回 201:

{
  "messageId": "6f1c…",
  "conversationId": "3a9e…",
  "conversationShortId": "cv-9f8e7d6c5b",
  "externalId": "wamid.HBgL…",
  "isNewConversation": false,
  "template": { "id": "…", "shortId": "wt-a1b2c3d4e5", "name": "pickup_confirmation", "language": "en_US" }
}

externalId 是 Meta 回报送达和已读回执时使用的 ID;这些回执稍后会到达对话。 201 表示 Meta 接受了这次发送,不代表手机已经收到。 把 conversationShortId 保存下来 —— 下文会用到。

消息落在哪里

在 /conversations 里,模板所属 WhatsApp 号码上与该号码的对话 —— 也就是顾客的 消息所到达的同一个对话:

  • 如果与这个号码的对话已经存在,模板会加入其中 —— 如果它是已关闭的,会被重新 打开。isNewConversation 为 false。
  • 如果不存在,会新开一个对话(对陌生号码还会新建一个联系人)。isNewConversation 为 true。
  • 气泡显示填入了你的值的正文,并带有 模板 · 和模板名称的标签。

顾客回复时,回复会落到同一个对话里 —— 而且通过 API 发送并不会把对话交给某个人。原本 就存在的对话,仍由原来处理它的一方继续处理;已关闭的或新的对话,会在接听这个 WhatsApp 号码的 AI 智能体名下变为活跃。所以只要这个号码有智能体,就由它来回答这条 回复,除非有人在 /conversations 里 接管了这个对话。

发送被拒绝时

状态code含义
400—to 不是 E.164 格式,或者 components 的结构不是 Meta 接受的;响应体会列出问题
402billing_not_eligible工作区还不能发送外发消息
404template_not_found这个工作区里没有这个 ID 的模板
409blocked_dnc该号码已退订。消息会以已拦截状态保留在对话里;不会有任何东西到达 Meta
422template_not_approvedMeta 还没有批准这个模板;status 说明它现在的状态
422template_components_mismatchcomponents 和模板对不上。error 会指出问题
422channel_unavailable模板所属的 WhatsApp 号码已被移除或停用
422channel_pending_verification, workspace_suspended, route_blocked针对号码或工作区的发送检查拒绝了它。消息会以已拦截或失败状态保留在对话里
429workspace_rate_limited你的工作区在这个端点上超出了速率。不会写入任何内容
429rate_limited这个 WhatsApp 号码的突发窗口已满 —— 等几秒到几分钟
429daily_limit_reached这个号码的每日模板上限已用完 —— 可能要等几个小时
502send_failedMeta 拒绝了这次发送;error 带着 Meta 的消息。消息会以失败状态留在对话里

每个 429 都带一个 Retry-After 头,响应体里还有一个对应的 retryAfterSec。等待这么 长时间后,重新发起同一个调用:第一次并没有发送、保存或计费任何消息。(发给一个新号码 的第一条消息如果被号码的额度拒绝,对话本身仍然已经建立,是空的;响应体里带着它的 conversationShortId。)

两类 429 是不同的限制。你的工作区 默认每分钟可以调用这个端点 300 次,突发时每秒 最多 10 次;这个速率只统计 API 调用,由 Omni Octo 支持团队按工作区设置。WhatsApp 号码 有它自己的发送额度,与收件箱、Octo 和营销活动共用 —— 一个号码能发多快 有详细说明。

不匹配时什么都不会发

调用会在写入任何内容之前,拿 components 和模板做比对。缺一个值、多一个值、未知 的名字、页眉类型不对、给不接受参数的按钮传了参数 —— 每一种都是 422 template_components_mismatch,不保存消息,也不发送任何东西。error 会准确说出哪里 不对:

body expects 2 parameters, got 1
body is missing variable "pickup_slot"
button 0 is a QUICK_REPLY button, got url
button 0 (a URL button) is missing its url parameter

所以不匹配的情况可以放心修正后重新发送。

这个调用不是幂等的

每一次成功的调用都会发送 —— 并计费 —— 一个模板。没有幂等键。如果一次调用超时或 连接中断,你无法知道它是否已经发出,盲目重试可能会发两次。

超时后重试之前,先检查对话 ——

GET /api/conversations/{conversationShortId}

用之前发给这个号码时拿到的 ID,或者在 /conversations 里打开这个对话。只有模板 不在里面时才重新发送。

上面那些拒绝不一样:4xx 明确告诉你这次发送没有发生。

这个端点做不到的事

  • 发送自由格式文本。 它只发已批准的模板 —— 要在窗口内回复,请用 收件箱。
  • 发给一个列表。 一次调用对应一个号码;列表由 营销活动处理。
  • 推翻 Meta 的决定。 即使返回了 201,营销类模板仍可能被 Meta 拒绝投递 —— 见原因。

接下来读什么