Create & send a Meta WhatsApp template programmatically (Cloud API)
● Create & send a Meta WhatsApp template programmatically (Cloud API).md
# Create & send a Meta WhatsApp template programmatically (Cloud API)
If you need to message a WhatsApp user **outside the 24-hour customer-service window** — first contact, marketing, transactional notifications — freeform text is rejected by Meta. You have to send an **approved Message Template**. This is the end-to-end flow: create the template in WhatsApp Manager, get it approved, send it via the Cloud API.
## 1. Prerequisites
- A WhatsApp Business Account (WABA) and a phone number connected to the **Cloud API hosted by Meta**.
- Admin access to that WABA in **Meta Business Manager**.
- A permanent **System User access token** with `whatsapp_business_messaging` + `whatsapp_business_management` scopes (don't use a temp dev token in production — it expires in 24h).
- The **Phone Number ID** (NOT the phone number) of the sender — find it in WhatsApp Manager → Phone numbers, or via `GET /{waba-id}/phone_numbers`.
## 2. Create the template in WhatsApp Manager
Go to **business.facebook.com → WhatsApp Manager → Message templates → Create template**.
⚠️ Don't confuse with **Template Groups** — that's just an analytics bucket for existing templates. The actual builder is under "Message templates".
Fill in:
| Field | Notes |
|---|---|
| **Category** | `Marketing` (promotions, announcements) · `Utility` (transactional: order updates, OTP-adjacent confirmations) · `Authentication` (OTP/2FA only). Wrong category is the #1 rejection reason. |
| **Name** | lowercase + underscores, e.g. `order_confirmation_v1`. You'll reference this exact string in the API call. |
| **Language** | Pick one (e.g. `English` = code `en`, `English (US)` = `en_US`). Add more languages later if you need to localize. |
| **Header** | Optional. Text, image, video, document, or location. |
| **Body** | The main text. Use positional placeholders `{{1}}`, `{{2}}` for dynamic fields. |
| **Footer** | Optional. Good place for `Reply STOP to unsubscribe` (often required to pass marketing review in stricter regions). |
| **Buttons** | Optional. Up to 10. Quick-reply buttons fire your webhook as `interactive` messages. URL/phone buttons take the user out of chat. |
| **Sample values** | Required. Provide example text for every `{{n}}` — Meta uses these to judge the template, not to send. |
| **Variable type** | Stick with **Number** (positional). "Named" looks nicer but is harder to call from code. |
### Body example
```
Your order #{{1}} ships on {{2}}. Track it here: {{3}}
```
Samples: `{{1}}=A1234`, `{{2}}=Tuesday`, `{{3}}=https://example.com/t/A1234`.
Click **Submit**. Status goes to **In review**. Approval is usually under an hour but can take up to 24h. If rejected, Meta gives a reason — fix and resubmit (keep the same name).
### Why templates get rejected
- Marketing template with no opt-out language.
- Promotional content tagged as Utility (or vice versa).
- URLs in samples that don't resolve.
- Placeholders that look like they'd render private data (PII).
- Emojis that violate policy (rare, but happens).
## 3. Test send via curl
Once Approved, send to your own phone first:
```bash
curl -sS -X POST "https://graph.facebook.com/v18.0/<PHONE_NUMBER_ID>/messages" \
-H "Authorization: Bearer <ACCESS_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"messaging_product": "whatsapp",
"to": "<RECIPIENT_E164_NO_PLUS>",
"type": "template",
"template": {
"name": "order_confirmation_v1",
"language": { "code": "en" },
"components": [
{
"type": "body",
"parameters": [
{ "type": "text", "text": "A1234" },
{ "type": "text", "text": "Tuesday" },
{ "type": "text", "text": "https://example.com/t/A1234" }
]
}
]
}
}'
```
Expected `200` response:
```json
{
"messaging_product": "whatsapp",
"contacts": [{"input": "919876543210", "wa_id": "919876543210"}],
"messages": [{"id": "wamid.HBg...", "message_status": "accepted"}]
}
```
`accepted` ≠ delivered — it means Meta queued it. The WhatsApp message lands seconds later. Use the **message status webhook** to track `sent → delivered → read`.
### Pure-text template (no variables)
For something like the built-in `hello_world` template:
```json
{
"messaging_product": "whatsapp",
"to": "...",
"type": "template",
"template": { "name": "hello_world", "language": { "code": "en_US" } }
}
```
No `components` needed.
### Template with a header image
```json
"components": [
{ "type": "header", "parameters": [
{ "type": "image", "image": { "link": "https://example.com/hero.jpg" } }
]},
{ "type": "body", "parameters": [ ... ] }
]
```
### Template with a URL button (dynamic suffix)
If the button URL was defined as `https://example.com/order/{{1}}`:
```json
{ "type": "button", "sub_type": "url", "index": "0",
"parameters": [ { "type": "text", "text": "A1234" } ] }
```
## 4. Programmatic send (Python)
```python
import httpx
API_URL = "https://graph.facebook.com/v18.0"
async def send_template(
phone_number_id: str,
access_token: str,
to: str,
template_name: str,
language_code: str = "en",
body_parameters: list[str] | None = None,
) -> dict:
payload = {
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": to,
"type": "template",
"template": {
"name": template_name,
"language": {"code": language_code},
},
}
if body_parameters:
payload["template"]["components"] = [{
"type": "body",
"parameters": [{"type": "text", "text": v} for v in body_parameters],
}]
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.post(
f"{API_URL}/{phone_number_id}/messages",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
},
json=payload,
)
r.raise_for_status()
return r.json()
```
## 5. Common errors
| HTTP | Error | Fix |
|---|---|---|
| 400 | `(#132000) Number of parameters does not match the expected number` | You passed too few/many `{{n}}` values. Count them in the approved body. |
| 400 | `(#132001) Template name does not exist in the translation` | Wrong `language.code`. Must match what was approved (e.g. `en` vs `en_US`). |
| 400 | `(#132012) Parameter format does not match format in the created template` | Header/button params missing or in wrong shape. Check the components array. |
| 400 | `Template is paused or disabled` | Quality dropped (too many user blocks/reports). Pause the campaign, fix the content, resubmit. |
| 401/403 | `Invalid OAuth access token` | Token expired or scopes missing. Mint a permanent System User token. |
| 470 | `Re-engagement message` | The 24h window expired *and* you tried freeform instead of a template. Switch to a template. |
## 6. Things that bite in production
- **Phone-number-level template approval.** A template can be `Approved` at the WABA level but not enabled on every phone number in that WABA. Check WhatsApp Manager → Phone numbers → the number → Templates list.
- **Marketing throttling.** For users in some regions (India, notably), Meta limits how many marketing templates a single user can receive **across all businesses per day**. Some sends silently don't deliver. Track delivery webhooks, not just `accepted` responses.
- **Sender quality rating.** WhatsApp Manager shows `Green / Yellow / Red`. Drop to Red and your sending tier shrinks. Don't blast marketing to cold lists.
- **Opt-out handling.** When users reply STOP, you must stop sending them marketing templates within a reasonable window. Persist that state in your DB and check it before sending.
- **Idempotency.** Meta doesn't dedupe by your client-side message id — if you retry on a network error and the original actually delivered, the user gets two messages. Either confirm delivery via webhooks before retrying, or carry your own dedup key keyed on `(user_id, template_name, business_day)`.
- **Webhooks for inbound.** If your template has quick-reply buttons, users tapping them shows up in your webhook as `messages[].interactive.button_reply` — handle that shape, not just plain text.
## 7. Useful Meta API endpoints
- `POST /{phone-number-id}/messages` — send (template, text, image, etc.)
- `GET /{waba-id}/message_templates` — list templates, statuses, rejection reasons
- `POST /{waba-id}/message_templates` — create templates via API (alternative to the UI; same review pipeline)
- `DELETE /{waba-id}/message_templates?name={name}` — delete
- `GET /{phone-number-id}` — phone number metadata, quality rating, throughput limit
Docs: https://developers.facebook.com/docs/whatsapp/cloud-api/reference
drop the URL into your AI agent — it pulls the note via npad's API and runs the whole thing in one shot.