Search for a command to run...
Guides
Create WhatsApp message templates — for your own business or on your clients' behalf — track them through Meta's review, and send them once approved.
WhatsApp only lets a business message someone freely for 24 hours after that person's last message. Outside that window — an order update, a reminder, a promotion — the message has to use a template that Meta reviewed and approved in advance.
Each template belongs to one client's WhatsApp Business Account, so every call here is scoped by client_ref. Two of your clients can both have a template called order_update without collision.
If you let your own customers author templates from your product, this is the flow to build around:
POST /v1/templates with their client_ref.PENDING. Store the returned id against your own record.template.status webhook — or poll GET /v1/templates/{id}. Update your UI so the customer sees Approved or the rejection reason.APPROVED, send it with POST /v1/messages/send-template.Never send on PENDING
APPROVED templates deliver. Sending by template_ref enforces this for you — anything else returns 400 template_not_approved (with the rejection reason when Meta rejected it) instead of an opaque Cloud API failure that would count against the client's quality rating. Still gate your own UI on status === "APPROVED" rather than on the create having succeeded.Copy the integration contract into your assistant, then let it tailor the implementation to your stack.
You are helping me build WhatsApp message template management into my product using the Intelli Partner API (base URL: https://api.intelliconcierge.dev/v1). My customers are businesses whose WhatsApp accounts are already connected through Intelli. I want each of them to compose their own templates inside my product and get them approved by Meta before they can send.
Before writing code, ask me:
1. My backend framework/language (and frontend framework if relevant).
2. Whether my customers author templates through a form I build, or paste raw Meta component JSON.
3. How I store per-customer records today, so template state can hang off the same model.
Environment variables (server-side only — the API key must NEVER reach the browser):
- INTELLI_API_KEY: my secret key. ik_test_... keys simulate every send except to my registered test recipients (my own phone/Instagram), which receive the real message; ik_live_... delivers for real.
- INTELLI_WEBHOOK_SECRET: HMAC secret for verifying incoming webhooks (from the Intelli portal, Webhooks section).
Every API call: Authorization: Bearer <INTELLI_API_KEY>. Errors are JSON {"error": {"code": "...", "message": "...", "details": {...}}}.
Key concepts to respect:
- A template belongs to ONE customer's WhatsApp Business Account, so every call is scoped by client_ref (my own stable id for that customer). Two customers can each own a template named order_update without collision.
- Meta reviews every template asynchronously. A create returns status PENDING. ONLY a template with status APPROVED can be sent — gate your send path on the status, never on the create having succeeded.
- name and language are immutable once created. Category is MARKETING, UTILITY or AUTHENTICATION, and Meta may reclassify on review.
- Required scopes: templates:read for listing/reading, templates:write for create/edit/delete/media upload.
Implement:
A) Create a template
POST /templates
{
"client_ref": "<my id for this customer>",
"name": "order_update", // normalized to lower_snake_case
"language": "en_US",
"category": "UTILITY",
"header": { "format": "TEXT", "text": "Order {{1}}", "examples": ["#4821"] },
"body": { "text": "Hi {{1}}, your order ships {{2}}.", "examples": ["Ama", "Friday"] },
"footer": { "text": "Reply STOP to opt out" },
"buttons": [{ "type": "QUICK_REPLY", "text": "Track order" }]
}
-> 201 with the full template, including "id" (Meta's template id) and "status": "PENDING". Persist that id against my own record — it is what the webhook will reference.
- Every {{n}} placeholder needs a sample in "examples", in order. Reviewers read the filled-in message, so make the samples realistic; anything I omit is auto-filled with a generic value and is more likely to be declined.
- Handle 409 code "template_exists": that name already exists in that language for this customer. Show my customer a name-taken error.
- Handle 400 code "meta_rejected": Meta refused it outright. The message is the reason — surface it verbatim.
- If I already build Meta's component array myself, pass "components": [...] instead of header/body/footer/buttons; it takes precedence.
B) Media headers (image, video, document)
Two options — pick whichever fits where my file lives:
- Public URL: pass "header": {"format": "IMAGE", "media_url": "https://..."}. Intelli downloads it and uploads it to Meta.
- Raw bytes (e.g. a browser upload): POST /templates/media as multipart/form-data with fields client_ref and file -> 201 {"handle": "..."}, then pass "header": {"format": "IMAGE", "media_handle": "<handle>"}.
Limits: JPEG/PNG up to 5MB, MP4 up to 16MB, PDF up to 100MB. The uploaded file is only the sample Meta reviews — each send can supply its own media of the same type.
C) Track approval (this is the part that matters)
Preferred — the webhook:
- My existing webhook endpoint receives event "template.status" whenever Meta finishes reviewing.
- Payload: {"event": "template.status", "channel": "whatsapp", "client_ref": "...", "timestamp": "...", "template": {"id": "...", "name": "...", "language": "...", "status": "APPROVED"|"REJECTED"|"PAUSED"|..., "category": "...", "reason": null}}
- Match on template.id (the id the create returned), falling back to template.name + template.language.
- On APPROVED: mark the template sendable in my UI. On REJECTED: show template.reason to my customer and let them edit and resubmit.
- Verify X-Intelli-Signature: "sha256=" + hex(HMAC_SHA256(INTELLI_WEBHOOK_SECRET, raw request body)), constant-time comparison. Respond 2xx immediately and process asynchronously.
Fallback — polling:
- GET /templates/{template_id}?client_ref=... returns the current status and rejected_reason. Poll with backoff; do not poll in a tight loop. Most reviews finish in minutes, but Meta allows itself up to 24 hours.
D) List, edit, delete
- GET /templates?client_ref=...&status=APPROVED — also filters on category, language and search (name substring). Add refresh=true only when I need a forced pull from Meta; the normal response is already kept fresh.
- PATCH /templates/{template_id} with {"client_ref": "...", ...only the fields that changed}. It is partial — omitted components are kept. Editing sends the template back through review, so its status returns to PENDING. Rejected with 409 "template_not_editable" while a review is still running; wait for it to settle.
- DELETE /templates/{template_id}?client_ref=... removes it from the customer's WhatsApp account. Any send referencing it fails afterwards, and Meta blocks re-using the name for 30 days. Confirm destructively in my UI.
E) Sending an approved template
POST /messages/send-template with {"client_ref": "...", "to": "<phone>", "template_ref": "order_update", "parameters": ["Ama"]}
- template_ref is the template id or name (add "language" when the name exists in several languages). "parameters" fills the body variables: an array for positional {{1}}, {{2}}… or an object for named variables. A dynamic header takes "header" ({"text": ...} or {"link"/"id": ...}); dynamic URL buttons take "buttons" (array of values in button order).
- The API resolves the template, refuses anything not APPROVED (400 template_not_approved, with the rejection reason when Meta rejected it), and builds Meta's components for me. The "variables" object on a template response tells me which placeholders it expects.
- Carousel templates add "cards": one object per card, in card order — {"header": {"link"|"id": ...}, "parameters": [...], "buttons": [...]}. Every card needs its header media at send time; quick replies take the payload my webhook receives on tap, dynamic URLs take their suffix. Errors name the card (missing_cards, missing_card_buttons).
- Media at send time: prefer an uploaded id over a link in production — POST /media (multipart: client_ref, file) returns a media_id valid 30 days that I reference as header.id (or image.id on free-form sends). A link is re-fetched by Meta at every delivery and fails the message silently when unreachable.
- Alternative for full control: pass a raw Meta "template" object ({"name": ..., "language": {"code": ...}, "components": [...]}) instead of template_ref — exactly one of the two.
F) Error handling
- 400 invalid_template (my payload), 400 meta_rejected (Meta refused), 401 invalid/revoked key, 403 missing templates:read / templates:write scope, 404 client or template not found, 409 template_exists / template_not_editable / ambiguous_template (name exists in several languages — pass ?language=), 429 rate-limited (respect Retry-After).
Model the template lifecycle explicitly in my data layer: store id, name, language, status and rejected_reason per customer, and drive the UI off status. Start with my ik_test_ key end-to-end, then switch to live. Write idiomatic, production-quality code for my stack.POST /v1/templates — requires the templates:write scope.
| Property | Type | Description |
|---|---|---|
client_refrequired | string | The client whose WhatsApp Business Account will own the template. Templates are never shared between clients. |
namerequired | string | Normalized to lower_snake_case. Must be unique per language for that client. |
language | string | Meta language code. Defaults to en_US. |
category | enum | MARKETING, UTILITY, or AUTHENTICATION. Defaults to MARKETING. Meta may reclassify on review. |
header | object | Optional. { format, text, examples } for TEXT; { format, media_handle } or { format, media_url } for IMAGE, VIDEO, DOCUMENT. |
bodyrequired | object | { text, examples }. Up to 1024 characters. Use {{1}}, {{2}} … for values supplied at send time. AUTHENTICATION templates take { add_security_recommendation } only — Meta writes the text. |
footer | object | Optional { text }, up to 60 characters. Cannot hold variables. AUTHENTICATION templates use { code_expiration_minutes } (1-90) instead of text. |
buttons | array | Optional. QUICK_REPLY, URL, or PHONE_NUMBER. AUTHENTICATION templates take exactly one { type: "OTP", otp_type: "COPY_CODE", text } button. See the button reference below. |
components | array | Raw Meta component array, if you already build one. Takes precedence over header/body/footer/buttons. |
{
"client_ref": "customer_42",
"name": "order_update",
"language": "en_US",
"category": "UTILITY",
"body": {
"text": "Hi {{1}}, your order {{2}} ships on {{3}}.",
"examples": ["Ama", "#4821", "Friday"]
},
"footer": { "text": "Reply STOP to opt out" },
"buttons": [
{ "type": "QUICK_REPLY", "text": "Track order" }
]
}{
"id": "1234567890123456",
"client_ref": "customer_42",
"name": "order_update",
"language": "en_US",
"category": "UTILITY",
"status": "PENDING",
"rejected_reason": null,
"template_type": "interactive",
"components": [ ... ],
"variables": { "header": [], "body": ["1", "2", "3"], "buttons": [] },
"created_at": "2026-07-26T09:14:02Z",
"updated_at": "2026-07-26T09:14:02Z"
}Variables need examples
{{n}} placeholders have no sample values, because reviewers read the filled-in message. Send realistic examples — one per placeholder, in order. Anything you omit is auto-filled with a generic placeholder, which reviewers are more likely to decline.Image, video and document headers need a sample file for review. There are two ways to supply it:
header.media_url — a public URL. Intelli downloads it and uploads it to Meta for you. Simplest when the file already lives in your storage.header.media_handle — upload the bytes to POST /v1/templates/media first and pass the returned handle. Use this when the file only exists in a browser upload.curl -X POST https://api.intelliconcierge.dev/v1/templates/media \
-H "Authorization: Bearer ik_live_..." \
-F "client_ref=customer_42" \
-F "file=@/path/to/header.jpg"
# → { "handle": "4::aW1hZ2U...", "format": "IMAGE", "size": 84213 }
# Pass that handle as header.media_handle when you create the template.Limits: JPEG/PNG up to 5MB, MP4 up to 16MB, PDF up to 100MB. The file you upload is only the review sample — each send can pass its own media of the same type.
| Status | Meaning |
|---|---|
| PENDING | Submitted and awaiting Meta's review. Not yet sendable. |
| APPROVED | Live. This is the only status you can send with. |
| REJECTED | Meta declined it. rejected_reason says why — fix and PATCH to resubmit. |
| PAUSED | Meta paused it after poor delivery feedback. It resumes automatically, or you can edit it. |
| DISABLED | Permanently disabled after repeated pauses. Create a new one. |
| IN_APPEAL | An appeal is under review at Meta. |
Delivered to your configured webhook endpoint whenever Meta changes a template's review state. It is the push half of the lifecycle — use it instead of polling.
{
"event": "template.status",
"channel": "whatsapp",
"client_ref": "customer_42",
"timestamp": "2026-07-26T09:31:44Z",
"template": {
"id": "1234567890123456",
"name": "order_update",
"language": "en_US",
"status": "APPROVED",
"category": "UTILITY",
"reason": null
},
"raw": { ... }
}Match on template.id (the same id the create returned), falling back to template.name plus template.language. The event is signed like every other webhook — see Webhooks for signature verification.
# List — filter by status, category, language or name
GET /v1/templates?client_ref=customer_42&status=APPROVED
# Read one, by Meta id or by name
GET /v1/templates/1234567890123456?client_ref=customer_42
GET /v1/templates/order_update?client_ref=customer_42&language=en_US
# Edit — sends the template back through review
PATCH /v1/templates/1234567890123456
{ "client_ref": "customer_42", "body": { "text": "Hi {{1}}, updated copy." } }
# Delete — removes it from the client's WABA
DELETE /v1/templates/1234567890123456?client_ref=customer_42Listing serves Intelli's mirror of the client's account and refreshes from Meta when it goes stale. Add refresh=true to force a live pull — useful right after a change made outside Intelli, unnecessary otherwise.
What you cannot change
name and language are fixed once Meta has it — create a new template instead. Meta also limits how often an approved template may be edited, and blocks re-using a deleted template's name for 30 days.templates:read covers listing and reading; templates:write covers create, edit, delete and media upload. A service that only needs to check approval before sending should hold templates:read alone.