How to Send a WhatsApp Template Message Using the API
Building a WhatsApp notification system? This guide covers all three ways to send template messages via Chakra's API, the two different variable-substitution patterns, handling errors 131049 and 131026, delivery tracking via webhooks, and rate-limited bulk sending.
If you're building notification systems on WhatsApp — order updates, appointment reminders, OTPs, re-engagement flows — you'll eventually need to send a template message programmatically. Chakra gives you three distinct ways to do this, each suited to a different integration pattern. This guide covers all three: their request structures, when to reach for each one, variable substitution, and handling the two errors you'll hit most often in production — 131049 and 131026.
This assumes you're already past "what is a WhatsApp template" — you have an approved WABA, approved templates, and an API key. If you need that setup, Chakra's API keys section is where you generate your bearer token.
Authentication, Quickly
Every request needs a bearer token generated from an API key with the appropriate role/privilege scope:
curl --request GET \
--url https://api.chakrahq.com/v1/ext/config \
--header 'Authorization: Bearer <your_access_token>'
Scope keys to least privilege, especially anything touching client-side code. Also worth knowing upfront: Chakra enforces a 200 API calls/minute rate limit — factor that into any bulk-send loop rather than discovering it via 429s in production.
The Three Ways to Send a Template Message
| Method | Endpoint Pattern | Best For |
|---|---|---|
| By Process | .../process/{procedureShortId}/{primaryKeyOrId}/send-template-message |
Sending tied to a CRM record — a lead, ticket, or case already in Chakra |
| By Phone Number | .../phoneNumber/{phoneNumber}/send-template-message |
Simple, standalone sends where you already have the recipient's number and don't need a linked record |
| Meta API Format | .../api/{whatsappApiVersion}/{whatsappPhoneNumberId}/messages |
Full control, exact Meta Cloud API payload — useful if you're porting existing Meta integration code or need message types Chakra's simplified endpoints don't abstract |
All three ultimately hit the same WhatsApp infrastructure — the difference is in payload shape and what context Chakra attaches to the send.
Method 1: Send by Process
Use this when the message is tied to a record already living in Chakra's platform — a lead, ticket, field visit, or any custom process type. This is the right call when you want the outbound message logged against that record automatically, without a separate step to associate them.
Endpoint:
POST https://api.chakrahq.com/v1/ext/plugin/whatsapp/{pluginId}/process/{procedureShortId}/{primaryKeyOrId}/send-template-message
pluginId— your WhatsApp connection's UUID, copyable from the plugin details page in the setup screenprocedureShortId— the type of record (lead,ticket,case, or a custom process shortId)primaryKeyOrId— the specific record's ID or primary key
curl example:
curl --location --request POST \
'https://api.chakrahq.com/v1/ext/plugin/whatsapp/d83e1d23-50b8-4d87-8f92-842a0ac516f6/process/lead/8842/send-template-message' \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data-raw '{
"whatsappPhoneNumberId": "775966265503012",
"templateName": "order_confirmation",
"mapping": [
{ "schemaPropertyName": "1", "schemaPropertyValue": "John" },
{ "schemaPropertyName": "2", "schemaPropertyValue": "ORD-4521" }
]
}'
JavaScript (fetch):
async function sendTemplateForLead(leadId, templateName, variables) {
const response = await fetch(
`https://api.chakrahq.com/v1/ext/plugin/whatsapp/${PLUGIN_ID}/process/lead/${leadId}/send-template-message`,
{
method: "POST",
headers: {
"Authorization": `Bearer ${API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
whatsappPhoneNumberId: PHONE_NUMBER_ID,
templateName,
mapping: variables.map((value, i) => ({
schemaPropertyName: String(i + 1),
schemaPropertyValue: value,
})),
}),
}
);
if (!response.ok) {
const error = await response.json();
throw new Error(`Template send failed: ${JSON.stringify(error)}`);
}
return response.json();
}
Method 2: Send by Phone Number
Functionally identical payload to Method 1, but you send directly against a phone number rather than a linked process record. Use this for anything where the record association isn't relevant — a webhook-triggered send from an external system (Shopify, WooCommerce, your own backend) that doesn't need a corresponding Chakra lead.
Endpoint:
POST https://api.chakrahq.com/v1/ext/plugin/whatsapp/{pluginId}/phoneNumber/{phoneNumber}/send-template-message
curl example:
curl --location --request POST \
'https://api.chakrahq.com/v1/ext/plugin/whatsapp/d83e1d23-50b8-4d87-8f92-842a0ac516f6/phoneNumber/919901258433/send-template-message' \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data-raw '{
"whatsappPhoneNumberId": "775966265503012",
"templateName": "shipping_update",
"mapping": [
{ "schemaPropertyName": "1", "schemaPropertyValue": "Priya" }
],
"imageUrl": "https://your-cdn.com/shipping-banner.png"
}'
JavaScript (fetch):
async function sendTemplateByPhone(phoneNumber, templateName, mapping, imageUrl) {
const body = { whatsappPhoneNumberId: PHONE_NUMBER_ID, templateName, mapping };
if (imageUrl) body.imageUrl = imageUrl;
const response = await fetch(
`https://api.chakrahq.com/v1/ext/plugin/whatsapp/${PLUGIN_ID}/phoneNumber/${phoneNumber}/send-template-message`,
{
method: "POST",
headers: {
"Authorization": `Bearer ${API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
}
);
return handleWhatsAppResponse(response); // shared error handler, see below
}
Note the imageUrl field — if your template has a header image component, provide a publicly reachable URL here rather than uploading media separately, unless you're using the Meta-format endpoint, which expects a media ID instead.
Method 3: Meta API Format (Raw Pass-Through)
This endpoint is a thin wrapper — Chakra passes your payload straight through to Meta's own Cloud API, in Meta's own schema. Reach for this when you already have Meta-format payload logic (ported from a previous direct-Cloud-API integration, or built against Meta's documentation), need message types or components Chakra's simplified endpoints don't cover, or want byte-for-byte parity with Meta's request/response contracts for your own logging.
Endpoint:
POST https://api.chakrahq.com/v1/ext/plugin/whatsapp/{pluginId}/api/{whatsappApiVersion}/{whatsappPhoneNumberId}/messages
curl example:
curl --location --request POST \
'https://api.chakrahq.com/v1/ext/plugin/whatsapp/d83e1d23-50b8-4d87-8f92-842a0ac516f6/api/v19.0/775966265503012/messages' \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data-raw '{
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": "919901258433",
"type": "template",
"template": {
"name": "order_confirmation",
"language": { "policy": "deterministic", "code": "en_US" },
"components": [
{ "type": "body", "parameters": ["John", "ORD-4521"] }
]
}
}'
JavaScript (fetch):
async function sendTemplateMetaFormat(to, templateName, langCode, bodyParams) {
const response = await fetch(
`https://api.chakrahq.com/v1/ext/plugin/whatsapp/${PLUGIN_ID}/api/v19.0/${PHONE_NUMBER_ID}/messages`,
{
method: "POST",
headers: {
"Authorization": `Bearer ${API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
messaging_product: "whatsapp",
recipient_type: "individual",
to,
type: "template",
template: {
name: templateName,
language: { policy: "deterministic", code: langCode },
components: [{ type: "body", parameters: bodyParams }],
},
}),
}
);
return handleWhatsAppResponse(response);
}
This same endpoint also accepts Meta's native payloads for session (free-form) messages — text, image, interactive lists — since it's a direct pass-through to the underlying /messages API, not a template-only endpoint.
Variable Substitution: Two Different Patterns
This trips people up when switching between methods, so it's worth being explicit:
Methods 1 & 2 (Chakra's abstraction) use a mapping array of { schemaPropertyName, schemaPropertyValue } pairs. schemaPropertyName is the template's variable position as a string ("1", "2", ...), and schemaPropertyValue is what gets substituted in. This is Chakra's own simplified schema, decoupled from Meta's raw component structure.
Method 3 (Meta format) uses Meta's native components array, where each component (header, body, button) carries its own parameters array, and variables are filled positionally in order. If your template has multiple variables in the body, parameters: ["John", "ORD-4521"] fills {{1}} and {{2}} respectively, in array order — there's no explicit index field, so getting the order right matters.
If you're building a system that might swap between endpoints later (say, starting simple and later needing raw Meta features), it's worth writing an internal adapter function that converts your own variable list into whichever shape the endpoint you're calling expects, rather than duplicating variable-mapping logic across your codebase.
Handling the Errors You'll Actually See
Error 131049 — Ecosystem engagement / not delivered. This isn't a malformed-request error; it's WhatsApp's system declining to deliver because the recipient likely won't want it (low recent engagement, high blocks, or hitting a per-user marketing cap across businesses generally). Don't retry the same send immediately — an immediate retry loop just repeats the failure and can make things worse for that contact. Log it, exclude the contact from the next send cycle, and retry after a delay (24 hours is a reasonable floor) using a Utility template if the context allows.
Error 131026 — Recipient not on WhatsApp / unreachable. The number isn't valid on WhatsApp, hasn't accepted current terms, or can't receive this message type. This is a data-quality problem, not a transient one — retrying won't fix it. Flag the contact for list cleanup rather than requeueing.
A shared error handler across all three methods might look like:
async function handleWhatsAppResponse(response) {
if (response.ok) return response.json();
const errorBody = await response.json();
const errorCode = errorBody?.error?.code || errorBody?._data?.error?.code;
switch (errorCode) {
case 131049:
// Don't retry now. Queue for a delayed retry, exclude from current campaign.
await flagForDelayedRetry(errorBody, { minDelayHours: 24 });
break;
case 131026:
// Bad number — don't retry, flag for cleanup.
await flagInvalidNumber(errorBody);
break;
default:
// Log and surface unexpected errors for manual review.
console.error("Unhandled WhatsApp send error:", errorBody);
}
throw new Error(`WhatsApp send failed (${errorCode}): ${JSON.stringify(errorBody)}`);
}
Building this kind of classification into your send pipeline early saves you from a retry loop quietly hammering the same failing numbers, which is one of the fastest ways to damage your Quality Rating.
Tracking Delivery Status After the Send
A 200 response from any of the three endpoints only confirms the message was accepted for sending — it doesn't confirm delivery or read status. For that, you need to consume Chakra's inbound events webhook, which fires status updates (sent, delivered, read, failed) as they happen, keyed against the whatsappMessageId or externalId returned in your original send response.
A minimal webhook handler:
app.post("/webhooks/whatsapp", (req, res) => {
const event = req.body;
if (event.type === "message_status") {
const { messageId, status, errorCode } = event.data;
// status: sent | delivered | read | failed
updateMessageRecord(messageId, { status, errorCode });
if (status === "failed" && errorCode) {
handleWhatsAppResponse({ ok: false, json: async () => ({ error: { code: errorCode } }) });
}
}
res.sendStatus(200); // acknowledge quickly; don't block on downstream processing
});
Storing the whatsappMessageId alongside your own record (order ID, lead ID, whatever triggered the send) at send time is what makes this webhook actually useful — otherwise you're stuck matching status events back to context after the fact.
Sending at Volume: Respecting the Rate Limit
At 200 calls/minute, a naive loop over a few thousand recipients will hit 429s fast. A simple queue-and-throttle pattern handles this without over-engineering it:
async function sendBatch(recipients, templateName, buildMapping) {
const RATE_LIMIT = 200; // calls per minute
const DELAY_MS = Math.ceil(60000 / RATE_LIMIT);
for (const recipient of recipients) {
try {
await sendTemplateByPhone(recipient.phone, templateName, buildMapping(recipient));
} catch (err) {
console.error(`Send failed for ${recipient.phone}:`, err.message);
// classification already handled inside handleWhatsAppResponse
}
await new Promise((resolve) => setTimeout(resolve, DELAY_MS));
}
}
For anything beyond a few thousand sends, a proper job queue (BullMQ, SQS, or similar) with retry backoff is worth the setup over an in-process loop — it survives a process restart mid-batch, which a simple for loop doesn't.
Why Build This on Chakra
A few things worth knowing if you're evaluating where to build this integration:
- Comprehensive docs — code snippets, request/response examples, and webhook tutorials across every endpoint, so you're not reverse-engineering behavior from trial and error
- Enterprise infrastructure — 99.9% uptime with built-in retry logic and rate-limit handling on Chakra's side, which matters when you're sending at volume and can't afford silent message loss
- A thin, honest wrapper — the Meta-format endpoint is genuinely just a pass-through, so nothing about Meta's own documentation stops applying; you're not learning a proprietary abstraction layer for every use case
- Transparent, zero-markup pricing — you pay standard Meta rates on template messages, with no hidden margin layered on top
- Real-time webhooks — inbound events and delivery status changes are available as they happen, so your system can react to failures (like the 131049/131026 cases above) without polling
If you're building a notification system that needs to scale past a handful of test sends, it's worth building against Chakra's API directly rather than gluing together lower-level Meta Cloud API calls yourself — the retry logic, rate-limit handling, and CRM-linked sending are already there.
Get your API key and start sending — sign up, connect your WABA, and you can be sending your first template message in minutes.