Creating & managing templates
Templates are the pre-approved messages that let you start a conversation and message a customer outside the 24-hour window. This page covers creating templates (utility, marketing, media-header and authentication/OTP), uploading header sample media, editing, listing and the validation rules — over the app-key API.
With an app key, creating, editing and uploading need the whatsapp:templates scope; listing needs only whatsapp. With your org API key you can also delete templates and see every status, including rejected ones. Sending an already-approved template is a separate call (/send-template) covered in Template messages.
Before you start
Get an app key that holds whatsapp:templates (see Scopes) and find your channel id — every template lives on the WhatsApp Business Account behind one Meta Cloud number:
curl https://api.telenow.ai/api/app-whatsapp/channels \
-H "Authorization: Bearer vai_app_…"
{ "success": true, "data": { "channels": [
{ "id": "b1e2c3d4-…", "label": "Acme Support", "phone": "+14155550100", "kind": "cloud" }
] } }
Use that id as the {id} in the paths below. Send the key on every request:
Authorization: Bearer vai_app_xxxxxxxxxxxxxxxx
Templates work on Meta Cloud numbers — native ("WhatsApp Business, set up for you") or connected through your own Meta app. On your own Meta app, a media header also needs the connection's Meta App ID saved, or the upload returns
400. With your org API key, use the org routes instead.
Create a utility template
POST /channels/{id}/templates submits a template definition to Meta for review and mirrors it locally with the status Meta returns — normally PENDING. The body is camelCase:
| Field | Type | Notes |
|---|---|---|
name | string | Required. Lowercase letters, digits and _ only, ≤ 512 chars. Immutable after creation |
language | string | Required. BCP-47 tag, e.g. en_US, en_GB, hi |
category | string | Required. One of MARKETING, UTILITY, AUTHENTICATION |
components | array | Required, at least one. Meta’s definition array (see below). Meta expects an example for each {{n}} |
allowCategoryChange | bool | Optional. Let Meta re-categorise (e.g. UTILITY → MARKETING) instead of rejecting |
A utility template with two body variables — note the example.body_text (a list of one row of sample values, one per {{n}}):
curl -X POST https://api.telenow.ai/api/app-whatsapp/channels/b1e2c3d4-…/templates \
-H "Authorization: Bearer vai_app_…" \
-H "Content-Type: application/json" \
-d '{
"name": "order_update",
"language": "en_US",
"category": "UTILITY",
"components": [
{ "type": "BODY",
"text": "Hi {{1}}, your order {{2}} has shipped and will arrive soon.",
"example": { "body_text": [["Asha", "#1234"]] } }
]
}'
{ "success": true, "data": { "template": {
"id": "7c1a9f22-…",
"name": "order_update",
"language": "en_US",
"category": "UTILITY",
"status": "PENDING",
"qualityScore": null,
"rejectedReason": null,
"metaTemplateId": "1443219876543210"
} } }
The template is now PENDING at Meta. See Approval & status for how it becomes APPROVED.
Create a marketing template
A richer template: a text header with a variable, a body, a footer, and a full set of buttons — a dynamic URL, a QUICK_REPLY, a PHONE_NUMBER, and a COPY_CODE coupon. Every variable ({{n}}) in the header, body and URL carries its own example:
curl -X POST https://api.telenow.ai/api/app-whatsapp/channels/b1e2c3d4-…/templates \
-H "Authorization: Bearer vai_app_…" \
-H "Content-Type: application/json" \
-d '{
"name": "spring_sale",
"language": "en_US",
"category": "MARKETING",
"components": [
{ "type": "HEADER", "format": "TEXT", "text": "{{1}} sale is live",
"example": { "header_text": ["Summer"] } },
{ "type": "BODY",
"text": "Hi {{1}}, enjoy 20% off everything this week. Use your code at checkout.",
"example": { "body_text": [["Asha"]] } },
{ "type": "FOOTER", "text": "Reply STOP to opt out" },
{ "type": "BUTTONS", "buttons": [
{ "type": "URL", "text": "Shop now", "url": "https://shop.example.com/{{1}}",
"example": ["spring"] },
{ "type": "QUICK_REPLY", "text": "No thanks" },
{ "type": "PHONE_NUMBER", "text": "Call us", "phone_number": "+14155550100" },
{ "type": "COPY_CODE", "example": "SAVE20" }
] }
]
}'
{ "success": true, "data": { "template": {
"id": "9d33be07-…",
"name": "spring_sale",
"language": "en_US",
"category": "MARKETING",
"status": "PENDING",
"qualityScore": null,
"rejectedReason": null,
"metaTemplateId": "1443219876500000"
} } }
Button notes:
URL— static, or dynamic with a trailing{{1}}in theurlplus anexamplevalue for the variable part.QUICK_REPLY— a tappable reply; the tap arrives back to you as abuttonreply on thewhatsapp.message.receivedwebhook.PHONE_NUMBER— dialsphone_number(E.164).COPY_CODE— a coupon button;exampleis a sample code. (This is the marketing coupon copy-code button — different from the OTP copy-code used in authentication templates.)
Create a media-header template
For an image / video / document header, Meta needs a sample asset at definition time. That’s a two-step flow: upload the sample to get a handle, then reference it in the header’s example.header_handle.
Step 1 — upload the sample asset. POST /channels/{id}/templates/media takes a multipart/form-data body with a single file field. Accepts image/jpeg, image/png, video/mp4, application/pdf, up to 16 MB:
curl -X POST https://api.telenow.ai/api/app-whatsapp/channels/b1e2c3d4-…/templates/media \
-H "Authorization: Bearer vai_app_…" \
-F "[email protected]"
{ "success": true, "data": { "handle": "4::aW1hZ2UvcG5n:ARZ…" } }
Step 2 — create the template with the returned handle in an IMAGE (or VIDEO / DOCUMENT) header:
curl -X POST https://api.telenow.ai/api/app-whatsapp/channels/b1e2c3d4-…/templates \
-H "Authorization: Bearer vai_app_…" \
-H "Content-Type: application/json" \
-d '{
"name": "receipt_with_logo",
"language": "en_US",
"category": "UTILITY",
"components": [
{ "type": "HEADER", "format": "IMAGE",
"example": { "header_handle": ["4::aW1hZ2UvcG5n:ARZ…"] } },
{ "type": "BODY",
"text": "Hi {{1}}, here is your receipt for order {{2}}.",
"example": { "body_text": [["Asha", "#1234"]] } },
{ "type": "FOOTER", "text": "Thanks for shopping with Acme" }
]
}'
{ "success": true, "data": { "template": {
"id": "2a71c0e5-…", "name": "receipt_with_logo", "language": "en_US",
"category": "UTILITY", "status": "PENDING",
"qualityScore": null, "rejectedReason": null, "metaTemplateId": "1443219876511111"
} } }
Use "format": "VIDEO" (upload an mp4) or "format": "DOCUMENT" (upload a pdf) for those header types. When you later send this template you supply the runtime asset — the mediaId from a normal media upload — in the header parameter; see Templates with a media header.
Create an authentication (OTP) template
Authentication templates use a fixed shape — you don’t write the body text. Meta generates the OTP wording; you only toggle the security recommendation, set the code expiry, and add the copy-code button. A custom body would be rejected.
curl -X POST https://api.telenow.ai/api/app-whatsapp/channels/b1e2c3d4-…/templates \
-H "Authorization: Bearer vai_app_…" \
-H "Content-Type: application/json" \
-d '{
"name": "login_code",
"language": "en_US",
"category": "AUTHENTICATION",
"components": [
{ "type": "BODY", "add_security_recommendation": true },
{ "type": "FOOTER", "code_expiration_minutes": 5 },
{ "type": "BUTTONS", "buttons": [
{ "type": "OTP", "otp_type": "COPY_CODE", "text": "Copy code" }
] }
]
}'
{ "success": true, "data": { "template": {
"id": "5f0d84a1-…", "name": "login_code", "language": "en_US",
"category": "AUTHENTICATION", "status": "PENDING",
"qualityScore": null, "rejectedReason": null, "metaTemplateId": "1443219876522222"
} } }
- The BODY component is optional; if you include it,
add_security_recommendationmust betrueorfalse(it appends Meta’s “For your security, don’t share this code” line), and it can't carrytext. - The FOOTER needs
code_expiration_minutes(1–90), which shows the customer when the code expires and greys out the copy button afterwards. - Exactly one OTP button, and only
COPY_CODE— one-tap autofill needs Android app-signing details that aren’t collected yet. No media header.
When you send this template, WhatsApp needs the code twice — once for the body and once for the copy-code button. On a Meta Cloud number, pass it once as variables: ["482913"] and Telenow puts it on the button too (see one-time-passcode templates). If you send components, they go out as written, so include both parts:
"components": [
{ "type": "body", "parameters": [ { "type": "text", "text": "482913" } ] },
{ "type": "button", "sub_type": "url", "index": 0, "parameters": [ { "type": "text", "text": "482913" } ] }
]
Approval & status
A create normally returns status: "PENDING". Meta review usually lands within minutes and moves the template to APPROVED, REJECTED or PAUSED (later statuses also include DISABLED, IN_APPEAL and PENDING_DELETION). How to observe it:
- Poll the list endpoint.
GET /channels/{id}/templatesreturns only approved templates — once your template appears there, it’s live. The list is refreshed from Meta at most every 10 minutes, so an approval can take that long to show. - See every status with your org API key.
GET /api/orgs/{orgId}/whatsapp-cloud/channels/{channelId}/templatesre-reads Meta on every call and returns all templates — pending, rejected and paused included — withrejected_reasonandquality_score. See With your org API key. - No webhook for template status.
whatsapp.account.healthfires only for account/number quality and review outcomes (account_update,phone_number_quality_update,account_review_update), never for template approval/rejection/pause.
The create/edit response echoes the live status, plus qualityScore (Meta’s green/yellow/red rating, null until it has traffic) and rejectedReason (populated when status is REJECTED, e.g. a policy or formatting reason).
Validation limits
The server checks these rules before forwarding to Meta, so a malformed template fails with a clear 400 instead of an opaque Meta rejection:
| Rule | Limit |
|---|---|
name | Lowercase letters, digits, _; ≤ 512 chars |
category | MARKETING | UTILITY | AUTHENTICATION |
| BODY | Exactly one non-empty BODY (marketing/utility); text ≤ 1024 |
| HEADER text | ≤ 60 chars |
| FOOTER text | ≤ 60 chars |
| Button text | ≤ 25 chars each |
| Buttons | ≤ 10 total |
| Variables | Contiguous {{1}}…{{N}} (no gaps) in the BODY text |
Authentication templates follow their own rules instead (see above); the length limits don't apply to them. Telenow doesn't check example values or header/URL variables — Meta does, and its refusal comes back as 400 with Meta's explanation.
Edit a template
PATCH /templates/{templateId} updates an existing template’s components (and optionally category). name and language are immutable at Meta — you can’t change them; create a new template instead. Editing re-submits for review, so the template returns to PENDING.
curl -X PATCH https://api.telenow.ai/api/app-whatsapp/templates/7c1a9f22-… \
-H "Authorization: Bearer vai_app_…" \
-H "Content-Type: application/json" \
-d '{
"components": [
{ "type": "BODY",
"text": "Hi {{1}}, your order {{2}} is out for delivery today.",
"example": { "body_text": [["Asha", "#1234"]] } }
]
}'
{ "success": true, "data": { "template": {
"id": "7c1a9f22-…", "name": "order_update", "language": "en_US",
"category": "UTILITY", "status": "PENDING",
"qualityScore": null, "rejectedReason": null, "metaTemplateId": "1443219876543210"
} } }
Approved, rejected and paused templates can all be edited in place; a PENDING one returns 400 only approved, rejected or paused templates can be edited. components is required in every edit — a category-only change is refused. The same validation limits apply.
List approved templates
GET /channels/{id}/templates returns the channel’s approved templates and, for each, how many variables it takes — everything you need to call /send-template:
curl https://api.telenow.ai/api/app-whatsapp/channels/b1e2c3d4-…/templates \
-H "Authorization: Bearer vai_app_…"
{ "success": true, "data": { "templates": [
{ "name": "order_update", "language": "en_US", "category": "UTILITY",
"variables": [ { "index": 1 }, { "index": 2 } ] },
{ "name": "login_code", "language": "en_US", "category": "AUTHENTICATION",
"variables": [] }
] } }
variables lists the {{n}} placeholders in the template's body text, in order — pass one variables string per index when you send. An authentication template has no body text of its own, so its list can be empty — it still takes exactly one value, the code: send variables: ["<code>"] on a Meta Cloud number, or components (see above). Templates still PENDING or REJECTED are not returned here; use the org-key list to see those.
Endpoint reference
Base /api/app-whatsapp, Meta Cloud numbers. Rate-limited ~20 req/s (burst 60) per app, plus the shared per-IP limit (see Errors & limits); template-media upload capped at 16 MB.
| Method | Path | Scope | Purpose |
|---|---|---|---|
GET | /channels/{id}/templates | whatsapp | List approved templates |
POST | /channels/{id}/templates | whatsapp:templates | Create a template → { template } |
POST | /channels/{id}/templates/media | whatsapp:templates | Upload a header sample → { handle } |
PATCH | /templates/{tid} | whatsapp:templates | Edit a template → { template } |
Not supported yet: carousel and limited-time-offer templates (Telenow doesn't check or send them), and one-tap OTP.
With your org API key
With your org API key (X-API-Key: vai_live_…), template management lives under /api/orgs/{orgId}/whatsapp-cloud/channels/{channelId}/templates. It also works on Meta Cloud numbers only (other numbers return 404). Two differences to watch: the create body uses snake_case allow_category_change (a camelCase allowCategoryChange is silently ignored here), and responses return the template as a snake_case row.
| Method | Path (under …/whatsapp-cloud/channels/{channelId}) | What it does | Key's creator |
|---|---|---|---|
GET | /templates | Every template — any status — re-read from Meta → { templates, plane, supportsMediaHeader, statusNote, complete, syncError } | any member |
POST | /templates | Create → { template }. Body { name, language, category, components, allow_category_change? } | owner/admin |
POST | /templates/media | Upload a header sample (multipart file, same types, 16 MB) → { handle } | owner/admin |
PATCH | /templates/{templateId} | Edit and resubmit → { template }. Body { components, category? } | owner/admin |
DELETE | /templates/{templateId} | Delete — every language of that template name → { success, message } | owner/admin |
Each row carries id, name, language, category, status, quality_score, rejected_reason, components, variables, meta_template_id, waba_id and timestamps. {templateId} is that row's id. Delete removes the template at Meta and in Telenow; if Meta's delete fails, the Telenow copy is removed anyway.
To list what a number can send — on every kind of number, including partner numbers — use GET /api/orgs/{orgId}/whatsapp-hub/channels/{channelId}/templates (see Template messages). Writes need an owner, admin or developer key.
Related
- WhatsApp API — the full REST surface, including sending an approved template.
- Broadcasts — send an approved template to many contacts.
- Reading conversations · Typing indicators & read receipts.
- Webhooks & events — inbound messages, statuses and account-health.
- Scopes, permissions & security · WhatsApp product guide.