Send a WhatsApp Template
Sends a pre-approved template to a WhatsApp recipient. Templates can be sent at any time, including outside Meta's 24-hour customer-service window — this is the right endpoint for proactive sends like order confirmations, appointment reminders, OTP verification, and re-engagement messages.
Which endpoint do I call?
POST to /v1/whatsapp/templates over HTTPS. The endpoint requires the whatsapp:send scope on your API key — same scope as free-form text sends.
POST /v1/whatsapp/templates
What does the request look like?
A minimal call needs an agent, a from-phone, a recipient, and the template name + language. Most real sends also include parameters to fill in placeholders, and many templates also require buttons[], header_parameters, or header_media_url depending on what they were approved with.
curl -X POST https://api.mojeeb.app/v1/whatsapp/templates \
-H "Authorization: Bearer mk_live_YOUR_KEY_HERE" \
-H "Content-Type: application/json" \
-d '{
"agent_id": "12345678-1234-1234-1234-123456789012",
"from": "+15557654321",
"to": "+15551234567",
"template": {
"name": "arabic_signup_message",
"language": "ar"
}
}'
With body parameters:
curl -X POST https://api.mojeeb.app/v1/whatsapp/templates \
-H "Authorization: Bearer mk_live_YOUR_KEY_HERE" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-12345-shipped" \
-d '{
"agent_id": "12345678-1234-1234-1234-123456789012",
"from": "+15557654321",
"to": "+15551234567",
"template": {
"name": "order_shipped",
"language": "en",
"parameters": {
"customer_name": "Sara",
"tracking_url": "https://example.com/track/A123"
}
}
}'
What goes in the request body?
The first four fields are required for any send. The rest are optional and only matter when your template was approved with the matching component.
| Field | Type | Required | Description |
|---|---|---|---|
agent_id | UUID | Yes | The agent to send from. |
from | E.164 string | Yes | The WhatsApp business number to send from. |
to | E.164 string | Yes | Recipient's phone. |
template.name | string | Yes | Meta-approved template name (e.g. order_shipped). |
template.language | string | Yes | BCP-47 language tag (e.g. en, ar, en_US). |
template.parameters | object | No | Body placeholders. Flat name → value map. Works for both POSITIONAL ({{1}}) and NAMED ({{customer_name}}) templates. |
template.header_parameters | object | No | TEXT-header placeholder values. Independent from parameters — Meta numbers header and body placeholders separately. Header allows one placeholder maximum. |
template.header_media_url | string | No | For IMAGE / VIDEO / DOCUMENT header templates — a public URL pointing at the asset to send. Omitted → Mojeeb uses the template's approval-time sample image. |
template.buttons | array | No | Per-button values for templates with dynamic buttons. Each entry targets one button by its 0-based index. See How do buttons work? |
How do body parameters work?
Templates can have placeholders in their body — e.g. a template body of Hello {{customer_name}}, your order is on the way: {{tracking_url}}. Pass values as a flat map:
"parameters": {
"customer_name": "Sara",
"tracking_url": "https://example.com/track/A123"
}
For POSITIONAL templates (e.g. body Hello {{1}}, your code is {{2}}), use the placeholder number as the key:
"parameters": {
"1": "Sara",
"2": "493281"
}
You don't need to tell us which style the template uses — Mojeeb detects it from the approved template body.
How do TEXT-header variables work?
Templates with a TEXT header can have a single placeholder in the header text — for example a header of Hello {{name}}. Send the value separately from body parameters:
curl -X POST https://api.mojeeb.app/v1/whatsapp/templates \
-H "Authorization: Bearer mk_live_YOUR_KEY_HERE" \
-H "Content-Type: application/json" \
-d '{
"agent_id": "12345678-1234-1234-1234-123456789012",
"from": "+15557654321",
"to": "+15551234567",
"template": {
"name": "promo_with_header",
"language": "en",
"header_parameters": { "promo_label": "Summer Sale" },
"parameters": { "customer_name": "Sara", "discount_pct": "30" }
}
}'
Meta numbers header and body placeholders separately, so both can use {{1}} without conflict — keep them in their own field. Headers allow at most one placeholder; templates with multi-placeholder headers won't pass Meta approval.
How do buttons work?
Templates can include dynamic buttons (URL, QUICK_REPLY, COPY_CODE, OTP, FLOW, MPM, CATALOG, ORDER_DETAILS). Each gets one entry in template.buttons[], keyed by its 0-based index in the approved template. The fields you provide depend on the button's sub-type:
| Approved button | Field to send | Example value |
|---|---|---|
URL (with {{1}} in URL) | url_suffix | "orders/12345" |
| OTP (AUTHENTICATION templates) | otp_code | "493281" |
| QUICK_REPLY | quick_reply_payload | "FEEDBACK_GOOD" |
| COPY_CODE (Marketing coupon) | coupon_code | "SPRING30" |
| FLOW / MPM / SPM / CATALOG / ORDER_DETAILS | action | object — see Meta's docs |
| PHONE_NUMBER / VOICE_CALL | — | Static; no value needed |
Dynamic URL button
A template's URL button can be approved with a {{1}} at the end of the URL — Meta substitutes your value at send-time:
"buttons": [
{ "index": 0, "url_suffix": "orders/12345" }
]
If the approved URL is https://shop.example.com/{{1}}, the customer sees a button that opens https://shop.example.com/orders/12345.
QUICK_REPLY button
The quick_reply_payload is echoed back to your webhook when the user taps the button — it's how you distinguish which button was tapped. The button label itself is fixed at template approval; this is just the routing string:
"buttons": [
{ "index": 0, "quick_reply_payload": "FEEDBACK_GOOD" },
{ "index": 1, "quick_reply_payload": "FEEDBACK_BAD" }
]
COPY_CODE button (Marketing coupon)
Maximum 15 characters per Meta's rules. The customer sees a button that copies the code to their clipboard:
"buttons": [
{ "index": 0, "coupon_code": "SPRING30" }
]
OTP button (AUTHENTICATION templates)
For one-tap, copy-code, and zero-tap OTP templates, send the verification code via otp_code. The code typically also appears in the body {{1}}, so pass it in both places:
curl -X POST https://api.mojeeb.app/v1/whatsapp/templates \
-H "Authorization: Bearer mk_live_YOUR_KEY_HERE" \
-H "Content-Type: application/json" \
-d '{
"agent_id": "12345678-1234-1234-1234-123456789012",
"from": "+15557654321",
"to": "+15551234567",
"template": {
"name": "otp_verification_v1",
"language": "en",
"parameters": { "1": "493281" },
"buttons": [
{ "index": 0, "otp_code": "493281" }
]
}
}'
Both copy-code and one-tap OTP buttons use the same wire shape — Meta picks the right rendering based on how the template was approved. otp_code is a friendlier alias for url_suffix; either works.
FLOW button
For interactive Flow templates, supply the action payload Meta documents. Mojeeb forwards it verbatim:
"buttons": [
{
"index": 0,
"action": {
"flow_token": "tok_3f4a91",
"flow_action_data": { "branch_id": "cairo-downtown" }
}
}
]
See Meta's Flow Templates docs for the full action shape per flow.
Static buttons (PHONE_NUMBER, VOICE_CALL, static URL)
If the approved button has no placeholder (e.g. a fixed phone number, or a URL with no {{1}}), omit it from buttons[]. Meta uses the value baked into the template.
How do custom header images work?
For templates with an IMAGE, VIDEO, or DOCUMENT header, you can send a different asset to each recipient via header_media_url:
curl -X POST https://api.mojeeb.app/v1/whatsapp/templates \
-H "Authorization: Bearer mk_live_YOUR_KEY_HERE" \
-H "Content-Type: application/json" \
-d '{
"agent_id": "12345678-1234-1234-1234-123456789012",
"from": "+15557654321",
"to": "+15551234567",
"template": {
"name": "order_shipped_with_photo",
"language": "en",
"header_media_url": "https://cdn.customer.example.com/shipments/A12345.jpg",
"parameters": { "tracking_no": "EG-A12345" }
}
}'
Constraints (enforced by Meta, not Mojeeb):
- HTTPS only.
- Must be publicly reachable from Meta's servers — Meta downloads the asset itself, so intranet, VPN-only, or IP-allowlisted URLs won't work.
- MIME and size must match Meta's per-format limits — typically JPG/PNG ≤ 5 MB for IMAGE, MP4 ≤ 16 MB for VIDEO, PDF ≤ 100 MB for DOCUMENT.
- If the URL is unreachable or rejected, the send transitions to
status: failed(visible via Get Message Status).
When you omit header_media_url, Mojeeb sends the approval-time sample image for every recipient — useful when the same asset is fine for everyone.
What does a success response look like?
HTTP 202 Accepted:
{
"id": "01c45011-d803-4eb0-a762-3228a4c392f3",
"status": "queued",
"agent_id": "12345678-1234-1234-1234-123456789012",
"to": "+15551234567",
"type": "template",
"platform_message_id": null,
"created_at": "2026-04-30T09:18:31Z",
"sent_at": null,
"failed_at": null
}
Use the returned id with Get Message Status to track delivery.
What can go wrong?
The standard error envelope. Template-specific cases:
code | HTTP | When |
|---|---|---|
invalid_template_name | 422 | template.name is missing or blank |
invalid_request_body | 422 | Required field missing — param names which |
from_phone_not_found_for_agent | 422 | from doesn't match any active WhatsApp connection — available_phones lists what would work |
agent_not_authorized | 403 | API key isn't allowed to use this agent |
template_button_value_missing | 422 | A dynamic button needs a value but none was supplied — param names the missing field |
template_header_variable_missing | 422 | TEXT-header placeholder needs a value but header_parameters doesn't include it |
template_header_too_many_parameters | 422 | Header has more than one {{n}} placeholder — fix the template, headers allow only one |
template_header_kind_mismatch | 422 | header_media_url supplied to a TEXT-header template, or header_parameters supplied to a media-header template |
template_header_media_url_invalid | 422 | header_media_url isn't a valid public HTTPS URL |
mixed_parameter_format | 422 | Template mixes {{1}} and {{name}} placeholders — pick one style |
These validation errors fire before the send is enqueued (synchronous 422). Meta-side failures (template_paused_by_meta, template_broken_at_meta, recipient_unreachable, etc.) surface asynchronously — the request still returns 202 with a queued message id, and you discover the failure by polling Get Message Status.
Common questions
How do I create a template?
Templates are created and approved through Meta's WhatsApp Business Manager, then become available to send. This is a one-time setup per template, separate from your Mojeeb integration. The Mojeeb dashboard surfaces your existing templates under the agent's WhatsApp connection.
What's the difference between language en and en_US?
BCP-47 language tags. Meta uses these to pick the right template translation. en is generic English; en_US is US English specifically. Use whatever language tag your template was approved with.
Can I omit parameters if my template has no placeholders?
Yes. Omit the parameters field entirely (or pass an empty object). Templates with no placeholders ignore parameters anyway. Same applies to header_parameters, header_media_url, and buttons.
Does the API validate parameters against the template's expected placeholders?
Yes. Mojeeb checks every dynamic button has a value, every required TEXT-header placeholder has an entry in header_parameters, and that you haven't mixed POSITIONAL/NAMED styles within one template. Failures return a 422 with a specific code before the send is queued.
What we don't validate locally: the actual button-type-to-field match (e.g. you supplied coupon_code for a button Meta knows as URL). Those reach Meta and the send transitions to status: failed.
Can I send the same template twice safely?
Yes — use Idempotency-Key. See Idempotency.
Can I upload an image directly instead of hosting it myself?
Not in v1 — header_media_url requires you to host the asset on a public HTTPS URL. Customers with private CDNs need to expose a Meta-reachable URL (signed URLs, a public bucket, etc.). A direct-upload endpoint may land in a future version.
What's the maximum size for header_media_url?
Limits are Meta's, not Mojeeb's. As of 2026: JPG/PNG ≤ 5 MB, MP4 ≤ 16 MB, PDF ≤ 100 MB. See Meta's media specs for the current numbers.
How do I find a button's index?
Buttons are 0-based in the order they appear in the approved template. The first button is index: 0, the second is index: 1, etc. Static buttons (PHONE_NUMBER, VOICE_CALL, URL without a placeholder) keep their index even though you don't send values for them — pass dynamic-button values keyed by their literal position in the template.