←all writing
0911 Aug 2026/6 min read

WhatsApp message templates: six things the docs did not prepare us for

Building WhatsApp Cloud API templates into the Happy Pet Tech SaaS: polling for approval, a template stuck on PENDING, uploading the header file twice, and other problems with their fixes.

WhatsAppNode.jsSaaS

Three weeks ago I wrote about our WhatsApp inbox. That post ended at the 24 hour window: outside it, a business can only send a template. A template is a message whose wording Meta has approved in advance. Booking confirmations and “your pet is ready” messages are all templates.

In our product a business creates templates in the app, and the app submits them to Meta. Sending a template is easy. Managing them for many businesses is where the surprises were. Here are six.

1. We learn the approval status by asking

A new template starts as PENDING. Meta reviews it and it becomes APPROVED or REJECTED. This often takes a few minutes.

We do not rely on Meta telling us. We ask.

When a business opens its templates page, the server fetches the list from Meta and compares each status with the one we saved. If a status changed, we save the new one and send the owner a notification: “your template was approved”.

The notification has a key made of the template name and the new status. So if two people open the page in the same minute, the owner still gets one notification.

2. A template that stayed PENDING for days

Asking only when someone opens the page has an obvious hole. If nobody opens the page, nobody learns the status.

So on July 23 we added a job that runs every 15 minutes and checks the status for them. But that job only looked at templates used by automations, because those were the ones where a wrong status blocked something.

Two weeks later a customer reported a template stuck on PENDING. It was a normal template with a document in its header. Meta had approved it long before. It was not an automation template, so the job never looked at it, and the customer had not reopened the page in a way that refreshed it.

The fix widened the job to every template that is PENDING or has never been checked:

const companies = await TemplateState.distinct('company', {
  is_deleted: { $ne: true },
  $or: [{ status: 'PENDING' }, { status: null }],
});

for (const company of companies) {
  try {
    await listTemplates(company); // fetches from Meta and saves any change
  } catch (err) {
    // one business's expired token must not stop the rest
  }
}

The try inside the loop matters. Without it, one business with an expired token would stop the whole run, and every business after it in the list would stay stale.

We made two more changes the same day:

  • Every template now stores last_polled_at. Before this, “still PENDING” could mean Meta was slow or our job was dead, and we could not tell which. Now we can.
  • The refresh button on the page really asks Meta. Before, on one tab, it only re-read our saved copy. A refresh button that shows the same old data is worse than no button.

3. The header file has to be uploaded twice

A template can have an image, video or document in its header. Getting this to work took two uploads of the same file, to two places, for two different reasons.

Upload one is for the review. Meta wants to see a sample of the header file when it reviews the template. You upload it with Meta’s resumable upload API and get back a handle. One detail cost us time: this API wants the token with the OAuth scheme in the header, where the rest of the Cloud API uses Bearer.

Upload two is for sending. The handle from upload one is only a sample. When you send the template later, you must give a link to the real file. So we also store the file in our own S3 bucket. Without that second copy, the send fails with error 132012.

We also check the file size when the user picks the file: 5 MB for images, 16 MB for video. For documents we set our own limit of 25 MB. Meta allows 100 MB, but Meta downloads the file from our link at send time, and a very large file makes that step slow and fragile.

One more small thing: documents arrived on the customer’s phone named “Untitled”. The send call has a filename field. Once we filled it, the real name showed.

4. A deleted name is gone forever

A business deletes a template called booking_confirmed and later creates a new one with the same name. Meta refuses.

Meta keeps the name of a deleted template reserved. So we remember every name a business has ever used. When a new template would clash, we add a number: booking_confirmed_1.

The same rule applies to a rejected template. There is no “edit and resubmit” in our app. A fixed template is submitted as a new one, with a new name.

A template button can open a link with a variable part, like a link to a customer’s agreement. The rules are strict. The variable must be the last thing in the URL. And the example you give for the review must be only that last part.

We gave a full URL as the example. The preview showed:

https://app.example.com/sign-agreementhttps://example.com/agreement

Two URLs stuck together. The example must be just the suffix.

6. Helping the owner when Meta says no

When a template is rejected, Meta gives a short reason code. A salon owner cannot do anything with INVALID_FORMAT.

So we fetch the reason and show advice in plain words for the common ones:

  • For a format rejection, the most likely cause is a link inside the body text. We say so.
  • We strip emoji and invisible characters from the body before submitting, because they cause rejections that nobody can see the reason for.
  • We check before submit that the body does not start or end with a variable, and we warn when a message is mostly variables and few real words.
  • Templates created in our app use named variables like {{customer_name}}, not {{1}}. They are much easier for an owner to read and much harder to fill in the wrong order.

We offer two categories, utility and marketing. When an owner picks marketing, the app warns them that Meta limits how many marketing messages one person receives, so some will not be delivered however well the template is written.

What is not a template

One thing is easy to miss. The app has three kinds of saved messages: Manual, Chat and Auto. Only Chat and Auto are Meta templates.

Manual messages are plain text. When a staff member uses one, it opens WhatsApp on their own phone with the text filled in. It never goes through the API and needs no approval. For a business that has not connected the Cloud API yet, this still gives them saved messages from day one.

The rule I took from all this

Meta owns the status of a template, the file, the name and the rules. Our database only has a copy. Every bug above came from treating our copy as the truth for too long.

So now every copied value has a time next to it, and there is always a way to ask again.

← all writing nextBuilding a team WhatsApp inbox on the WhatsApp Cloud API →