A WhatsApp template send can return 200 and a message ID and still never reach the customer. The send is accepted first and delivered (or not) later, and the failure, if Meta reports one, arrives in a statuses webhook you might not be watching. The causes we've hit in production are header media Meta couldn't fetch, a parameter count that didn't match the template, and a copy-code button sent without its coupon. Build every send from the template's definition, refuse to send when a value is missing, send media as an uploaded ID instead of a link, and treat "accepted" as unconfirmed until a status arrives.
The snippets are small, self-contained versions of the patterns, and each was run against the cases shown.
Accepted is not delivered
When you POST /<PHONE_NUMBER_ID>/messages with a template, a successful response means Meta has accepted the request. Delivery is reported afterwards as a status webhook: sent, delivered, read, or failed with an errors array. Meta's error codes reference says errors can come back in the API response, in a webhook, or both.
That gap is where silent failures live. Local and staging environments often have no webhook at all, so every send there looks like a clean success. In production, a message that is accepted and then dropped leaves your database saying "sent" forever.
Silent failure 1: media Meta can't fetch
A template with an image, video or document header can take the media as a link or as an uploaded media id. Meta's template media guide recommends uploading the media and sending its ID, and warns that a link "must be on a publicly accessible server or the message will fail to send".
"Publicly accessible" is harder than it sounds. We had a carousel template whose card images were on a URL that loaded fine from a laptop, returning 200 and the full image, and the message still never arrived. Our sends returned 200 with a message ID every time; the customer got nothing. Switching to uploaded media IDs fixed it.
The catch is that a template's media doesn't all sit in one place. A carousel keeps its header media inside each card, one level below the top-level components, so code that only swaps the top-level header misses it:
// Replace every media `link` in a template payload with an uploaded media ID,
// including the headers inside carousel cards, one level further down.
type Upload = (link: string) => Promise<string>;
const swapHeader = async (component: any, upload: Upload) => {
if (component?.type !== "header") return;
for (const param of component.parameters ?? []) {
const media = param[param.type];
if (media?.link && !media.id) param[param.type] = { id: await upload(media.link) };
}
};
export const useMediaIds = async (components: any[], upload: Upload) => {
for (const component of components) {
await swapHeader(component, upload);
if (component?.type === "carousel") {
for (const card of component.cards ?? []) {
for (const inner of card.components ?? []) await swapHeader(inner, upload);
}
}
}
return components;
};
Upload once and cache the ID, rather than uploading on every send. Meta's media guide gives the size limits: images 5 MB (JPEG, PNG), video 16 MB, documents 100 MB. A content type that doesn't match the file is a common cause of error 131053, "Unable to upload the media used in the message."
Silent failure 2: the wrong number of values
Meta checks the parameters you send against the template you got approved. The wrong count fails with 132000, a missing required one with 131008, and a badly formatted value with 132012.
The tempting fix is to make the count always match, padding missing values with the sample text you submitted for review. Don't. The send then succeeds, and your customer receives the reviewer's example: someone else's name, an order number that isn't theirs, "Fri 18 Oct" for an appointment next week. A refused send is a bug you can see. A send with sample text is a wrong message a customer can see.
Build the components from the template's definition, and refuse when a value is missing or blank:
// Build the send-time `components` for an approved template, or refuse.
export type TemplateDef = {
name: string;
bodyVariables: number;
header?: { format: "IMAGE" | "VIDEO" | "DOCUMENT" };
buttons: ({ type: "QUICK_REPLY" } | { type: "URL"; dynamic: boolean } | { type: "COPY_CODE" })[];
};
export type SendValues = {
body: string[];
headerMediaId?: string;
urlSuffix?: string;
couponCode?: string;
};
export class TemplateSendError extends Error {}
export const buildComponents = (def: TemplateDef, values: SendValues) => {
const components: object[] = [];
if (def.header) {
// An uploaded media ID, never a link Meta may fail to fetch later.
if (!values.headerMediaId) throw new TemplateSendError(`${def.name}: header needs a media ID`);
const kind = def.header.format.toLowerCase();
components.push({ type: "header", parameters: [{ type: kind, [kind]: { id: values.headerMediaId } }] });
}
// Exactly as many body values as the template has variables, none blank.
if (values.body.length !== def.bodyVariables) {
throw new TemplateSendError(`${def.name}: expected ${def.bodyVariables} body values, got ${values.body.length}`);
}
if (values.body.some((v) => !v.trim())) throw new TemplateSendError(`${def.name}: a body value is blank`);
if (def.bodyVariables > 0) {
components.push({ type: "body", parameters: values.body.map((text) => ({ type: "text", text })) });
}
def.buttons.forEach((button, index) => {
if (button.type === "URL" && button.dynamic) {
if (!values.urlSuffix) throw new TemplateSendError(`${def.name}: URL button ${index} needs a suffix`);
components.push({ type: "button", sub_type: "url", index: String(index), parameters: [{ type: "text", text: values.urlSuffix }] });
}
if (button.type === "COPY_CODE") {
if (!values.couponCode) throw new TemplateSendError(`${def.name}: copy-code button needs a coupon code`);
components.push({
type: "button",
sub_type: "copy_code",
index: String(index),
parameters: [{ type: "coupon_code", coupon_code: values.couponCode }],
});
}
});
return components;
};
This builder also covers the third failure.
Silent failure 3: button parameters
Some buttons need a value at send time, even though their label was fixed at approval:
-
A copy-code button needs a
coupon_codeparameter on every send. Meta's coupon template guide shows it as{"type":"button","sub_type":"copy_code","index":…,"parameters":[{"type":"coupon_code","coupon_code":"WINTER25"}]}. The code is up to 20 characters. -
A website button with a variable needs its suffix, sent the same way with
"sub_type":"url"and atextparameter, as in the send examples in Meta's components reference.
index is the zero-based position of the button in the template. Get it wrong and the send is rejected or the value lands on the wrong button. Quick replies and static website buttons need nothing at send time.
One builder, every send path
The bug behind the missing coupon wasn't really about coupons. Our app has two places that assemble template sends: one for campaigns, chatbots and automations, and one for the inbox and the API. Neither sent the coupon at first, and every new send-time component has to be added to both. If you can, route every send path through one builder, so a new component can't reach only half of them.
Know which failures you'll never see in the response
Some template errors fail the request straight away: in Meta's error codes reference, a template paused for low quality "cannot be sent" (132015), and one paused too many times is permanently disabled (132016). Others, such as media that can't be fetched, can be accepted and then fail. So track every send until a status arrives, and look for the ones that never get one:
// "200 + message ID" means accepted, not delivered. Track each send until a
// status arrives, and flag the ones that never get one.
export type Tracked = { wamid: string; sentAt: number; status: "accepted" | "sent" | "delivered" | "read" | "failed" };
const RANK = { accepted: 0, sent: 1, delivered: 2, read: 3, failed: 4 } as const;
export const applyStatus = (row: Tracked, status: Tracked["status"]): Tracked =>
RANK[status] > RANK[row.status] ? { ...row, status } : row;
export const silentSends = (rows: Tracked[], now: number, afterMs = 15 * 60 * 1000) =>
rows.filter((row) => row.status === "accepted" && now - row.sentAt > afterMs).map((row) => row.wamid);
A send that is still only "accepted" after 15 minutes isn't proof of failure, but it is worth investigating: check that your status webhook is subscribed and reaching you, then look at the message itself. On environments with no webhook, don't report delivery at all.
Checklist
- Treat
200and a message ID as "accepted", not "delivered". - Upload header media and send the ID, including the media inside carousel cards; cache the ID.
- Build components from the template's definition: the exact number of body values, none blank.
- Never fill a missing value with the template's sample text; refuse the send.
- Send the
coupon_codefor copy-code buttons and the suffix for dynamic website buttons, with the rightindex. - Route every send path through the same builder.
- Track statuses, and flag sends that never get one.
WAPGROWAI is built by DI Solutions, a custom software and AI development company. This series covers what we've learned building WAPGROWAI on the WhatsApp Cloud API. Related: rate limits in practice and from templates to campaigns.
Top comments (0)