agents/openai.yaml
interface: display_name: "Sent WABA Template Author" short_description: "Draft compliant WhatsApp templates" default_prompt: "Use $waba-template-author to draft and validate a WhatsApp template payload."
sentdm/sent-plugin · GitHub
Writes, classifies, validates, and repairs WhatsApp templates using the Sent v3 template definition contract. Use for utility, marketing, authentication, OTP, Meta review, rejected templates, variables, buttons, channel overrides, or submission-ready Sent payloads.
프로젝트 폴더에서 아래 명령어를 실행하고, 설치할 에이전트를 선택하세요.
npx skills add sentdm/sent-plugin --skill waba-template-author설치 명령을 직접 실행해야 적용됩니다. 지원 에이전트와 필요한 권한·라이선스는 제작자의 안내를 확인하세요.
agents/openai.yamlinterface: display_name: "Sent WABA Template Author" short_description: "Draft compliant WhatsApp templates" default_prompt: "Use $waba-template-author to draft and validate a WhatsApp template payload."
references/template-rejection-playbook.md# Template rejection and lifecycle playbook
Use this reference when a Sent template is pending, rejected, paused, disabled, or recategorized by the WhatsApp provider.
## Keep lifecycle surfaces separate
Sent template resources have these known states:
- `DRAFT`
- `PENDING`
- `APPROVED`
- `REJECTED`
- `PAUSED`
The template webhook is a provider-forwarding surface. Common `payload.status` values are `PENDING`, `APPROVED`, `REJECTED`, and `CATEGORY_UPDATED`; provider values such as `PAUSED` and `DISABLED` can also arrive. These lists serve different purposes. Persist the original status string and surface unknown values safely.
## Correct template event envelope
```json
{
"field": "templates",
"timestamp": "2026-08-09T12:00:00Z",
"payload": {
"account_id": "00000000-0000-0000-0000-000000000000",
"template_id": "11111111-1111-1111-1111-111111111111",
"template_name": "order_update",
"whatsapp_template_id": "2222222222222222",
"status": "REJECTED",
"language": "en_US",
"category": "UTILITY",
"channel": "whatsapp",
"reason": "Promotional content is not utility content."
}
}
```
Template events use `field: "templates"` and omit both `sub_type` and `event`. Message events are different and do use `sub_type`.
## Response procedure
1. Verify the webhook signature using the raw body and reject stale timestamps.
2. Deduplicate on template ID plus status transition.
3. Persist the raw payload and reason.
4. Retrieve the current Sent template before editing; webhooks can be delayed or reordered.
5. Map the reason to the smallest justified change.
6. Convert any Meta-shaped source into the Sent `definition` contract.
7. Run the local linter and use `sandbox: true`.
8. Show the final diff and obtain confirmation before review submission.
## Common remediations
| Symptom | Appropriate response |
| --- | --- |
| Utility content recategorized | Remove promotion or deliberately use `MARKETING`; do not argue from transactional context alone. |
| Missing or unrealistic samples | Add `props.sample` for every placeholder without using customer data. |
| Invalid variable format | Replace naked placeholders with `{{0:variable}}` and align IDs. |
| Unsupported create shape | Move fields into `definition`; reject Meta `components[]` as a Sent request. |
| Button validation | Enforce 10 total and per-type limits; allow quick replies and CTA buttons to coexist. |
| `PAUSED` or `DISABLED` | Stop new WhatsApp sends with the template, preserve the provider value, and surface it for review. |
| Unknown status | Store and display it; do not silently coerce it to rejected or approved. |
Do not claim provider approval timing as a guarantee, and do not repeatedly resubmit unchanged content.
references/waba-template-categories.md# WhatsApp template categories
Supporting policy reference for `waba-template-author`. The request contract comes from Sent; category review is ultimately performed by Meta for WhatsApp.
## Decision order
1. Identify why the recipient expects the message.
2. Identify the single action the message asks them to take.
3. Remove optional promotional language and classify again.
4. If promotion remains, use `MARKETING`.
5. If the sole purpose is a verification code, use `AUTHENTICATION`.
6. Otherwise use `UTILITY` only when the message is tied to a specific transaction, account, or service event.
## Category guide
| Category | Suitable intent | Common rejection or recategorization risk |
| --- | --- | --- |
| `UTILITY` | Order state, appointment reminder, account change, service interruption, requested support update | Discounts, upsells, product discovery, vague re-engagement, or calls to purchase |
| `MARKETING` | Offers, launches, recommendations, reminders to shop, abandoned-cart messages, mixed promotional content | Missing consent, misleading urgency, or attempting to disguise promotion as utility |
| `AUTHENTICATION` | OTP, login verification, account recovery code | Free-form content, promotional text, unrelated links/media, or multiple actions |
Transactional context does not make promotional content utility. “Your receipt is ready” is utility; “Your receipt is ready—buy again for 20% off” is marketing.
## Authentication restrictions
- Set top-level `category` to `AUTHENTICATION`.
- Include `definition.authenticationConfig`.
- `codeExpirationMinutes`, when present, is an integer from 1 through 90.
- Keep the body to the verification purpose and one code variable.
- Use the supported `COPY_CODE` action for the code.
- Do not add promotion, unrelated URLs, media, or extra calls to action.
## Variables and samples
Provider reviewers see samples. Every placeholder such as `{{0:variable}}` must have the same numeric ID in the channel's variables array and a realistic `props.sample`. Do not use real customer data or secrets in samples.
## Revision discipline
When Meta returns `REJECTED` or `CATEGORY_UPDATED`, retain the raw reason, change only what it supports, lint again, and resubmit deliberately. Do not repeatedly submit unchanged content.
references/waba-template-examples.md# Sent template examples
All examples in the first section are bodies for `POST /v3/templates` and are expected to pass `scripts/lint_waba_template.py`. Synthetic values are used throughout.
## Utility with a WhatsApp override
<!-- sent-template-request -->
```json
{
"category": "UTILITY",
"language": "en_US",
"definition": {
"header": null,
"body": {
"multiChannel": {
"type": "body",
"template": "Hi {{0:variable}}, your appointment is on {{1:variable}}.",
"variables": [
{"id": 0, "name": "customerName", "type": "variable", "props": {"sample": "Avery"}},
{"id": 1, "name": "appointmentTime", "type": "variable", "props": {"sample": "August 14 at 10:30 AM"}}
]
},
"whatsapp": {
"type": "body",
"template": "Hello {{0:variable}}. Your appointment is confirmed for {{1:variable}}.",
"variables": [
{"id": 0, "name": "customerName", "type": "variable", "props": {"sample": "Avery"}},
{"id": 1, "name": "appointmentTime", "type": "variable", "props": {"sample": "August 14 at 10:30 AM"}}
]
}
},
"footer": {"type": "text", "template": "Acme Scheduling", "variables": []},
"buttons": [
{"id": 1, "type": "QUICK_REPLY", "props": {"text": "Confirm", "quickReplyType": "custom"}},
{"id": 2, "type": "URL", "props": {"text": "Manage booking", "urlType": "static", "url": "https://example.com/bookings"}}
],
"definitionVersion": "1.0",
"authenticationConfig": null
},
"creation_source": "from-api",
"submit_for_review": false,
"sandbox": true
}
```
## Authentication
<!-- sent-template-request -->
```json
{
"category": "AUTHENTICATION",
"language": "en_US",
"definition": {
"header": null,
"body": {
"multiChannel": {
"type": "body",
"template": "Your verification code is {{0:variable}}.",
"variables": [
{"id": 0, "name": "verificationCode", "type": "variable", "props": {"sample": "482193"}}
]
}
},
"footer": null,
"buttons": [
{"id": 1, "type": "COPY_CODE", "props": {"text": "Copy code", "offerCode": "482193"}}
],
"definitionVersion": "1.0",
"authenticationConfig": {
"addSecurityRecommendation": true,
"codeExpirationMinutes": 10
}
},
"creation_source": "from-api",
"submit_for_review": false,
"sandbox": true
}
```
## Meta Cloud API example — not a Sent request
The following abbreviated shape is deliberately separate. It must not pass the Sent linter or be posted to `POST /v3/templates`; convert its `components[]` into Sent's `definition` structure first.
```json
{
"name": "order_update",
"language": "en_US",
"category": "UTILITY",
"components": [
{"type": "BODY", "text": "Your order {{1}} has shipped."}
]
}
```
scripts/fixtures/utility_bad.json{
"name": "meta_cloud_shape",
"language": "en_US",
"category": "UTILITY",
"components": [
{
"type": "BODY",
"text": "This is Meta's components[] format, not the Sent v3 request body."
}
]
}
scripts/fixtures/utility_good.json{
"category": "UTILITY",
"language": "en_US",
"definition": {
"header": {
"type": "text",
"template": "Order update",
"variables": []
},
"body": {
"multiChannel": {
"type": "body",
"template": "Hi {{0:variable}}, order {{1:variable}} is ready.",
"variables": [
{
"id": 0,
"name": "customerName",
"type": "variable",
"props": {"sample": "Avery"}
},
{
"id": 1,
"name": "orderNumber",
"type": "variable",
"props": {"sample": "A-1042"}
}
]
},
"sms": {
"type": "body",
"template": "Order {{1:variable}} is ready.",
"variables": [
{
"id": 1,
"name": "orderNumber",
"type": "variable",
"props": {"sample": "A-1042"}
}
]
}
},
"footer": {
"type": "text",
"template": "Acme Support",
"variables": []
},
"buttons": [
{
"id": 1,
"type": "QUICK_REPLY",
"props": {"text": "Got it", "quickReplyType": "custom"}
},
{
"id": 2,
"type": "URL",
"props": {"text": "Track order", "urlType": "static", "url": "https://example.com/track"}
},
{
"id": 3,
"type": "VOICE_CALL",
"props": {"text": "Voice support", "countryCode": "US", "phoneNumber": "+12025550100"}
},
{
"id": 4,
"type": "PHONE_NUMBER",
"props": {"text": "Call support", "countryCode": "US", "phoneNumber": "+12025550101"}
},
{
"id": 5,
"type": "COPY_CODE",
"props": {"text": "Copy reference", "offerCode": "A-1042"}
}
],
"definitionVersion": "1.0",
"authenticationConfig": null
},
"creation_source": "from-api",
"submit_for_review": false,
"sandbox": true
}
scripts/lint_waba_template.py#!/usr/bin/env python3
"""Lint the JSON body sent to ``POST /v3/templates``.
This validator intentionally accepts the Sent v3 request contract, not Meta's
Cloud API ``components[]`` format. Meta payloads are useful reference material,
but must be labelled and converted before they are sent to Sent.
Exit codes:
0 - valid template payload (warnings may be printed)
1 - invalid payload or unreadable/malformed input
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from collections import Counter
from pathlib import Path
from typing import Any
TOP_LEVEL_FIELDS = {
"category",
"language",
"definition",
"creation_source",
"submit_for_review",
"sandbox",
}
CREATE_UNSUPPORTED_FIELDS = {"name", "channels", "body", "header", "buttons", "components"}
VALID_CATEGORIES = {"UTILITY", "MARKETING", "AUTHENTICATION"}
VALID_BODY_CHANNELS = {"multiChannel", "sms", "whatsapp", "rcs"}
VALID_BUTTON_TYPES = {"QUICK_REPLY", "URL", "VOICE_CALL", "PHONE_NUMBER", "COPY_CODE"}
BUTTON_LIMITS = {
"QUICK_REPLY": 10,
"URL": 2,
"VOICE_CALL": 1,
"PHONE_NUMBER": 1,
"COPY_CODE": 1,
}
LANGUAGE_RE = re.compile(r"^[a-z]{2}(?:_[A-Z]{2})?$")
PLACEHOLDER_RE = re.compile(r"\{\{(\d+):(variable|link|media)\}\}")
ANY_PLACEHOLDER_RE = re.compile(r"\{\{[^{}]+\}\}")
PROMO_WARN_PHRASES = (
"buy now",
"limited time",
"special offer",
"discount",
"sale",
"free shipping",
)
PROMO_FAIL_PHRASES = ("click here to purchase",)
class LintResult:
def __init__(self) -> None:
self.errors: list[tuple[str, str]] = []
self.warnings: list[tuple[str, str]] = []
def error(self, field: str, message: str) -> None:
self.errors.append((field, message))
def warn(self, field: str, message: str) -> None:
self.warnings.append((field, message))
@property
def failed(self) -> bool:
return bool(self.errors)
def _nonempty(value: Any) -> bool:
return isinstance(value, str) and bool(value.strip())
def _reject_unknown_fields(
value: dict[str, Any], allowed: set[str], field: str, result: LintResult
) -> None:
for key in sorted(set(value) - allowed):
result.error(f"{field}.{key}" if field else key, "field is not part of the Sent v3 request contract")
def _check_variable(
variable: Any,
field: str,
expected_kind: str | None,
result: LintResult,
) -> int | None:
if not isinstance(variable, dict):
result.error(field, "variable must be an object")
return None
for key in ("id", "name", "type", "props"):
if key not in variable:
result.error(f"{field}.{key}", "missing required variable field")
variable_id = variable.get("id")
if not isinstance(variable_id, int) or variable_id < 0:
result.error(f"{field}.id", "must be a non-negative integer")
variable_id = None
if not _nonempty(variable.get("name")):
result.error(f"{field}.name", "must be a non-empty string")
kind = variable.get("type")
if kind not in {"variable", "link", "media"}:
result.error(f"{field}.type", "must be variable, link, or media")
elif expected_kind is not None and kind != expected_kind:
result.error(f"{field}.type", f"placeholder declares {expected_kind!r}, but variable declares {kind!r}")
props = variable.get("props")
if not isinstance(props, dict):
result.error(f"{field}.props", "must be an object")
elif not _nonempty(props.get("sample")):
result.error(f"{field}.props.sample", "must be a non-empty review and preview sample")
return variable_id
def _check_content(content: Any, field: str, result: LintResult) -> None:
if not isinstance(content, dict):
result.error(field, "must be an object")
return
_reject_unknown_fields(content, {"type", "template", "variables"}, field, result)
template = content.get("template")
if not _nonempty(template):
result.error(f"{field}.template", "must be a non-empty string")
return
if len(template) > 1024:
result.error(f"{field}.template", f"body exceeds the 1,024-character limit ({len(template)})")
placeholders = [(int(match.group(1)), match.group(2)) for match in PLACEHOLDER_RE.finditer(template)]
malformed = [match.group(0) for match in ANY_PLACEHOLDER_RE.finditer(template) if not PLACEHOLDER_RE.fullmatch(match.group(0))]
if malformed:
result.error(
f"{field}.template",
"use Sent placeholders such as '{{0:variable}}'; malformed: " + ", ".join(malformed),
)
variables = content.get("variables", [])
if variables is None:
variables = []
if not isinstance(variables, list):
result.error(f"{field}.variables", "must be an array")
return
expected = {variable_id: kind for variable_id, kind in placeholders}
if len(expected) != len({variable_id for variable_id, _ in placeholders}):
result.error(f"{field}.template", "one placeholder id cannot be reused with different types")
actual_ids: list[int] = []
for index, variable in enumerate(variables):
variable_id = variable.get("id") if isinstance(variable, dict) else None
checked_id = _check_variable(
variable,
f"{field}.variables[{index}]",
expected.get(variable_id) if isinstance(variable_id, int) else None,
result,
)
if checked_id is not None:
actual_ids.append(checked_id)
duplicates = [str(key) for key, count in Counter(actual_ids).items() if count > 1]
if duplicates:
result.error(f"{field}.variables", "duplicate variable ids: " + ", ".join(duplicates))
missing = sorted(set(expected) - set(actual_ids))
extra = sorted(set(actual_ids) - set(expected))
if missing:
result.error(f"{field}.variables", f"missing definitions for placeholder ids {missing}")
if extra:
result.error(f"{field}.variables", f"variables without matching placeholders: {extra}")
def _check_header_or_footer(value: Any, field: str, limit: int, result: LintResult) -> None:
if value is None:
return
if not isinstance(value, dict):
result.error(field, "must be an object or null")
return
_reject_unknown_fields(value, {"type", "template", "variables"}, field, result)
template = value.get("template")
if not isinstance(template, str):
result.error(f"{field}.template", "must be a string")
return
if len(template) > limit:
result.error(f"{field}.template", f"exceeds the {limit}-character limit")
if field.endswith("footer") and (ANY_PLACEHOLDER_RE.search(template) or value.get("variables")):
result.error(field, "footer variables are not supported")
elif field.endswith("header"):
_check_content({"template": template, "variables": value.get("variables", [])}, field, result)
def _check_button(button: Any, index: int, result: LintResult) -> str | None:
field = f"definition.buttons[{index}]"
if not isinstance(button, dict):
result.error(field, "button must be an object")
return None
_reject_unknown_fields(button, {"id", "type", "props"}, field, result)
button_type = button.get("type")
if button_type not in VALID_BUTTON_TYPES:
result.error(f"{field}.type", f"must be one of {sorted(VALID_BUTTON_TYPES)}")
return None
props = button.get("props")
if not isinstance(props, dict):
result.error(f"{field}.props", "must be an object")
return button_type
text = props.get("text")
if not _nonempty(text) or len(text) > 25:
result.error(f"{field}.props.text", "must be 1–25 characters")
if button_type == "QUICK_REPLY" and not _nonempty(props.get("quickReplyType")):
result.error(f"{field}.props.quickReplyType", "is required for QUICK_REPLY")
elif button_type == "URL":
if not _nonempty(props.get("urlType")):
result.error(f"{field}.props.urlType", "is required for URL")
if not _nonempty(props.get("url")):
result.error(f"{field}.props.url", "is required for URL")
elif button_type in {"VOICE_CALL", "PHONE_NUMBER"}:
if not _nonempty(props.get("countryCode")):
result.error(f"{field}.props.countryCode", f"is required for {button_type}")
if not _nonempty(props.get("phoneNumber")):
result.error(f"{field}.props.phoneNumber", f"is required for {button_type}")
elif button_type == "COPY_CODE" and not _nonempty(props.get("offerCode")):
result.error(f"{field}.props.offerCode", "is required for COPY_CODE")
return button_type
def _check_definition(payload: dict[str, Any], result: LintResult) -> None:
definition = payload.get("definition")
if not isinstance(definition, dict):
result.error("definition", "required and must be an object")
return
_reject_unknown_fields(
definition,
{"header", "body", "footer", "buttons", "definitionVersion", "authenticationConfig"},
"definition",
result,
)
body = definition.get("body")
if not isinstance(body, dict):
result.error("definition.body", "required and must be an object")
else:
_reject_unknown_fields(body, VALID_BODY_CHANNELS, "definition.body", result)
if body.get("multiChannel") is None:
result.error("definition.body.multiChannel", "is required as the channel-neutral body")
for channel, content in body.items():
if channel in VALID_BODY_CHANNELS and content is not None:
_check_content(content, f"definition.body.{channel}", result)
_check_header_or_footer(definition.get("header"), "definition.header", 60, result)
_check_header_or_footer(definition.get("footer"), "definition.footer", 60, result)
buttons = definition.get("buttons", [])
if buttons is None:
buttons = []
if not isinstance(buttons, list):
result.error("definition.buttons", "must be an array or null")
buttons = []
elif len(buttons) > 10:
result.error("definition.buttons", f"at most 10 buttons are allowed, got {len(buttons)}")
counts = Counter(filter(None, (_check_button(button, index, result) for index, button in enumerate(buttons))))
for button_type, limit in BUTTON_LIMITS.items():
if counts[button_type] > limit:
result.error("definition.buttons", f"{button_type} allows at most {limit}, got {counts[button_type]}")
authentication = definition.get("authenticationConfig")
category = payload.get("category")
if authentication is not None:
if category != "AUTHENTICATION":
result.error("definition.authenticationConfig", "is only valid for AUTHENTICATION templates")
if not isinstance(authentication, dict):
result.error("definition.authenticationConfig", "must be an object or null")
else:
_reject_unknown_fields(
authentication,
{"addSecurityRecommendation", "codeExpirationMinutes"},
"definition.authenticationConfig",
result,
)
recommendation = authentication.get("addSecurityRecommendation")
if recommendation is not None and not isinstance(recommendation, bool):
result.error("definition.authenticationConfig.addSecurityRecommendation", "must be boolean")
expiration = authentication.get("codeExpirationMinutes")
if expiration is not None and (not isinstance(expiration, int) or not 1 <= expiration <= 90):
result.error("definition.authenticationConfig.codeExpirationMinutes", "must be an integer from 1 to 90")
if category == "AUTHENTICATION":
if authentication is None:
result.error("definition.authenticationConfig", "is required for AUTHENTICATION templates")
if any(button_type != "COPY_CODE" for button_type in counts):
result.error("definition.buttons", "AUTHENTICATION templates may only use COPY_CODE buttons")
if counts["COPY_CODE"] != 1:
result.error("definition.buttons", "AUTHENTICATION templates require exactly one COPY_CODE button")
def lint_template(payload: Any) -> LintResult:
result = LintResult()
if not isinstance(payload, dict):
result.error("<root>", "template payload must be a JSON object")
return result
if "components" in payload:
result.error(
"components",
"Meta Cloud API components[] is not a Sent payload; convert it to definition before POST /v3/templates",
)
for field in sorted(CREATE_UNSUPPORTED_FIELDS & set(payload)):
result.error(field, "unsupported top-level create field")
_reject_unknown_fields(payload, TOP_LEVEL_FIELDS, "", result)
category = payload.get("category")
if category is not None and category not in VALID_CATEGORIES:
result.error("category", f"must be one of {sorted(VALID_CATEGORIES)} or null")
language = payload.get("language")
if language is not None and (not isinstance(language, str) or not LANGUAGE_RE.fullmatch(language)):
result.error("language", "must look like en or en_US")
for field in ("submit_for_review", "sandbox"):
if field in payload and not isinstance(payload[field], bool):
result.error(field, "must be boolean")
_check_definition(payload, result)
if category == "UTILITY":
body = payload.get("definition", {}).get("body", {}).get("multiChannel", {})
text = body.get("template", "") if isinstance(body, dict) else ""
lowered = text.lower()
for phrase in PROMO_FAIL_PHRASES:
if phrase in lowered:
result.error("definition.body.multiChannel.template", f"UTILITY body contains promotional phrase {phrase!r}")
for phrase in PROMO_WARN_PHRASES:
if phrase in lowered:
result.warn("definition.body.multiChannel.template", f"Meta may reclassify promotional phrase {phrase!r} as MARKETING")
return result
def _format(prefix: str, entries: list[tuple[str, str]]) -> str:
return "\n".join(f"{prefix} {field}: {message}" for field, message in entries)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("path", type=Path, help="Sent POST /v3/templates request JSON")
args = parser.parse_args(argv)
try:
payload = json.loads(args.path.read_text(encoding="utf-8"))
except OSError as exc:
print(f"could not read {args.path}: {exc}", file=sys.stderr)
return 1
except json.JSONDecodeError as exc:
print(f"invalid JSON in {args.path}: {exc}", file=sys.stderr)
return 1
result = lint_template(payload)
if result.warnings:
print(_format("WARN", result.warnings))
if result.errors:
print(_format("FAIL", result.errors), file=sys.stderr)
return 1
print("OK")
return 0
if __name__ == "__main__":
raise SystemExit(main())
SKILL.md---
name: waba-template-author
description: Writes, classifies, validates, and repairs WhatsApp templates using the Sent v3 template definition contract. Use for utility, marketing, authentication, OTP, Meta review, rejected templates, variables, buttons, channel overrides, or submission-ready Sent payloads.
---
# WhatsApp Template Author
Use this skill to turn a messaging intent into a valid body for `POST /v3/templates`, review it for WhatsApp policy risk, and explain the resulting lifecycle. Sent's template request is not Meta's Cloud API `components[]` shape.
## Source precedence
When official sources disagree:
1. Use the live Sent v3 OpenAPI for paths, request fields, and response shapes.
2. Use the most specific current Sent guide for lifecycle and policy semantics.
3. Preserve unknown provider values instead of forcing them into a closed enum.
The canonical references are the Sent template-definition guide, the v3 OpenAPI, and the webhook events reference. Do not use snapshot-era v2 examples.
## Authoring workflow
### 1. Establish intent and category
Collect the business event, recipient expectation, requested action, language, channel overrides, and realistic sample values. Choose:
- `UTILITY` for a specific non-promotional transaction, account, or service event.
- `MARKETING` for promotions, offers, re-engagement, product discovery, or mixed promotional content.
- `AUTHENTICATION` for one-time verification codes and supported authentication flows.
If content mixes utility and promotion, classify it as marketing or split it. See [references/waba-template-categories.md](references/waba-template-categories.md).
### 2. Build the Sent create request
`POST /v3/templates` accepts these top-level fields:
| Field | Requirement |
| --- | --- |
| `definition` | Required. Contains `header`, `body`, `footer`, `buttons`, optional `definitionVersion`, and optional `authenticationConfig`. |
| `category` | Optional: `UTILITY`, `MARKETING`, or `AUTHENTICATION`; omit for detection only when ambiguity is acceptable. |
| `language` | Optional locale such as `en_US`. |
| `creation_source` | Optional source string; `from-api` is the documented default. |
| `submit_for_review` | Optional Boolean; default `false`. Draft and validate before review. |
| `sandbox` | Optional Boolean for validation without side effects. |
Do not put `name`, `channels`, `body`, `header`, `buttons`, or `components` at the request root. `name` exists on update/response surfaces, not on the current create request.
```json
{
"category": "UTILITY",
"language": "en_US",
"definition": {
"header": null,
"body": {
"multiChannel": {
"type": "body",
"template": "Hi {{0:variable}}, order {{1:variable}} has shipped.",
"variables": [
{
"id": 0,
"name": "customerName",
"type": "variable",
"props": {"sample": "Avery"}
},
{
"id": 1,
"name": "orderNumber",
"type": "variable",
"props": {"sample": "A-1042"}
}
]
},
"sms": null,
"whatsapp": null,
"rcs": null
},
"footer": null,
"buttons": null,
"definitionVersion": "1.0",
"authenticationConfig": null
},
"creation_source": "from-api",
"submit_for_review": false,
"sandbox": true
}
```
Use `definition.body.multiChannel` as the channel-neutral body. `sms`, `whatsapp`, and `rcs` are complete channel overrides, not fragments. Keep each body at or below 1,024 characters.
### 3. Define variables exactly
Use placeholders such as `{{0:variable}}`, `{{1:link}}`, or `{{2:media}}`. Each placeholder needs one matching definition with:
- a unique non-negative integer `id`;
- a readable `name`;
- a matching `type`;
- `props.sample` with realistic review and preview data.
Keep placeholder IDs and variable IDs aligned inside every body override. Never output naked `{{1}}` placeholders in a Sent request.
### 4. Add supported buttons
Sent currently recognizes `QUICK_REPLY`, `URL`, `VOICE_CALL`, `PHONE_NUMBER`, and `COPY_CODE`. Enforce:
- 10 buttons total;
- at most 2 URL buttons;
- at most 1 voice-call button;
- at most 1 phone-number button;
- at most 1 copy-code button;
- quick replies may use the remaining slots, up to the total of 10.
Buttons use `id`, `type`, and `props`. Labels are at most 25 characters. Require type-specific properties: `quickReplyType`; `urlType` and `url`; `countryCode` and `phoneNumber`; or `offerCode`. Quick replies and calls-to-action may coexist—do not invent an XOR rule.
### 5. Handle authentication templates
For `AUTHENTICATION`, use `definition.authenticationConfig`:
```json
{
"addSecurityRecommendation": true,
"codeExpirationMinutes": 10
}
```
Expiration is 1–90 minutes. Keep authentication content to the verification purpose, use one code variable and the supported copy-code action, and do not add marketing language, unrelated links, media, or promotional buttons.
### 6. Validate before submission
Run:
```bash
python scripts/lint_waba_template.py template.json
```
The linter validates the Sent request shape, variables, the 1,024-character limit, channel overrides, every current button type, per-type limits, and authentication configuration. A Meta Cloud API example with `components[]` must fail with an explicit conversion error.
Use `sandbox: true` and `submit_for_review: false` while integrating. When the user is ready for provider review, show the final payload and explain that submission changes external state before proceeding.
### 7. Track the right lifecycle surface
Sent template resources use the known states `DRAFT`, `PENDING`, `APPROVED`, `REJECTED`, and `PAUSED`. Do not claim this is every value the API may ever return.
Template webhooks are WhatsApp approval events. They use `field: "templates"`, omit `sub_type` and `event`, and carry the provider status in `payload.status`:
```json
{
"field": "templates",
"timestamp": "2026-08-09T12:00:00Z",
"payload": {
"account_id": "00000000-0000-0000-0000-000000000000",
"template_id": "11111111-1111-1111-1111-111111111111",
"template_name": "order_update",
"whatsapp_template_id": "2222222222222222",
"status": "APPROVED",
"language": "en_US",
"category": "UTILITY",
"channel": "whatsapp",
"reason": null
}
}
```
Common forwarded values include `PENDING`, `APPROVED`, `REJECTED`, and `CATEGORY_UPDATED`. Meta can also send values such as `PAUSED` or `DISABLED`. Persist the raw string, handle known values, and safely surface unknown ones. See [references/template-rejection-playbook.md](references/template-rejection-playbook.md).
## Boundaries
Use `template-builder-ui` for editor architecture and client-side validation UX. Use `sent-templates` to list, inspect, or delete existing templates through the connected Sent tools. Use `waba-embedded-signup` for WABA connection. Use `rcs-agent-onboarding` for current RCS launch capabilities.
Meta Cloud API payloads may appear in [references/waba-template-examples.md](references/waba-template-examples.md), but every such example must be clearly labelled non-Sent and must never be passed to the Sent linter as a valid request.