Skip to main content

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.

FieldTypeRequiredDescription
agent_idUUIDYesThe agent to send from.
fromE.164 stringYesThe WhatsApp business number to send from.
toE.164 stringYesRecipient's phone.
template.namestringYesMeta-approved template name (e.g. order_shipped).
template.languagestringYesBCP-47 language tag (e.g. en, ar, en_US).
template.parametersobjectNoBody placeholders. Flat name → value map. Works for both POSITIONAL ({{1}}) and NAMED ({{customer_name}}) templates.
template.header_parametersobjectNoTEXT-header placeholder values. Independent from parameters — Meta numbers header and body placeholders separately. Header allows one placeholder maximum.
template.header_media_urlstringNoFor 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.buttonsarrayNoPer-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 buttonField to sendExample value
URL (with {{1}} in URL)url_suffix"orders/12345"
OTP (AUTHENTICATION templates)otp_code"493281"
QUICK_REPLYquick_reply_payload"FEEDBACK_GOOD"
COPY_CODE (Marketing coupon)coupon_code"SPRING30"
FLOW / MPM / SPM / CATALOG / ORDER_DETAILSactionobject — see Meta's docs
PHONE_NUMBER / VOICE_CALLStatic; 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:

codeHTTPWhen
invalid_template_name422template.name is missing or blank
invalid_request_body422Required field missing — param names which
from_phone_not_found_for_agent422from doesn't match any active WhatsApp connection — available_phones lists what would work
agent_not_authorized403API key isn't allowed to use this agent
template_button_value_missing422A dynamic button needs a value but none was supplied — param names the missing field
template_header_variable_missing422TEXT-header placeholder needs a value but header_parameters doesn't include it
template_header_too_many_parameters422Header has more than one {{n}} placeholder — fix the template, headers allow only one
template_header_kind_mismatch422header_media_url supplied to a TEXT-header template, or header_parameters supplied to a media-header template
template_header_media_url_invalid422header_media_url isn't a valid public HTTPS URL
mixed_parameter_format422Template 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.