有些模板根本不是人发的。潮汐洗衣店希望预约系统一确认时段,取件确认就立刻发出去 —— 不该有人为了这个去打开收件箱。为此有一个端点:
POST https://api.omniocto.com/api/wa-templates/{id}/send
它把一个已批准的模板发给一个电话号码。从 webhook 处理程序或脚本里调用它 —— “订单发出时,发送取件模板”。因为是模板,24 小时窗口不适用 (原因)。
获取 API 密钥
密钥在 /settings/api-keys —— 齿轮菜单里的 管理与系统 → API 密钥。
- 点击 创建密钥。对话框 创建 API 密钥 会要你填一个 名称 (可选) —— 比如 “例如 CI 流水线”;用将要使用它的系统来命名。
- 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 |
components | Meta 自己的 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 接受的;响应体会列出问题 |
| 402 | billing_not_eligible | 工作区还不能发送外发消息 |
| 404 | template_not_found | 这个工作区里没有这个 ID 的模板 |
| 409 | blocked_dnc | 该号码已退订。消息会以已拦截状态保留在对话里;不会有任何东西到达 Meta |
| 422 | template_not_approved | Meta 还没有批准这个模板;status 说明它现在的状态 |
| 422 | template_components_mismatch | components 和模板对不上。error 会指出问题 |
| 422 | channel_unavailable | 模板所属的 WhatsApp 号码已被移除或停用 |
| 422 | channel_pending_verification, workspace_suspended, route_blocked | 针对号码或工作区的发送检查拒绝了它。消息会以已拦截或失败状态保留在对话里 |
| 429 | workspace_rate_limited | 你的工作区在这个端点上超出了速率。不会写入任何内容 |
| 429 | rate_limited | 这个 WhatsApp 号码的突发窗口已满 —— 等几秒到几分钟 |
| 429 | daily_limit_reached | 这个号码的每日模板上限已用完 —— 可能要等几个小时 |
| 502 | send_failed | Meta 拒绝了这次发送;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 拒绝投递 —— 见原因。
接下来读什么
- 把 WhatsApp 模板导入 Omni Octo —— 模板必须先获得批准并完成同步。
- 一个号码能发多快
——
rate_limited和daily_limit_reached背后的按号码额度。