On this page
Guides / WhatsApp templates through the API

WhatsApp templates through the API

Create WhatsApp templates from your code, find out when Meta approves or rejects them, and see why they belong to the account, not the number.

Updated View .md
StackNode · TypeScript SDK
Endpointschannels.list · channels.templates · channels.createTemplate · channel.template.approved

Outside the 24-hour window, the only thing WhatsApp lets through is a template approved by Meta. This guide is about creating them from your code, not from the panel.

First: a template does NOT belong to the number

This is the misunderstanding that costs the most, so it goes first.

A template belongs to the WhatsApp account — the WABA — not to a phone number. If your account has three numbers, the template you create is available to all three. There is no such thing as "a template for this number".

And if you have several accounts connected to the same channel, then it does matter which one you create it in: a template from account A cannot be sent from a number on account B.

The channel id

Everything starts with the channel:

const channels = await met.channels.list();
const whatsapp = channels.find((c) => c.type === 'whatsapp')!;

And the templates you already have, each with the account it came from:

const { data, accounts } = await met.channels.templates(whatsapp.id);

Always look at accounts. If one of your accounts didn't respond, the list arrives incomplete and with 200, not with an error. That's also where the waba_id values and their aliases come from — which is what you need next.

Creating a template

const created = await met.channels.createTemplate(whatsapp.id, {
  name: 'appointment_reminder',
  language: 'en',
  category: 'UTILITY',
  components: [
    { type: 'BODY', text: 'Hi {{1}}, a reminder about your appointment on {{2}}.' },
  ],
  waba_id: '102938475610293',   // optional: omit it and it goes to your primary account
});

created.template_status;   // 'PENDING'

It requires the channels:manage scope, and a test key is not enough: this sends a real review request to Meta against your client's account.

If the header carries an image

Meta requires a sample. Pass it as sample_url inside the HEADER component:

components: [
  {
    type: 'HEADER',
    format: 'IMAGE',
    sample_url: 'https://your-cdn.com/samples/invoice.png',
  },
  { type: 'BODY', text: 'Your {{1}} invoice is ready.' },
]

It must be https and reachable from the internet: our server downloads it to upload it to Meta, and for security it rejects internal and private-network addresses. Maximum 5 MB.

There is a cap, and it's worth understanding why

25 creations per account per hour. Past that you get a 429 with rate_limited.

The reason isn't protecting our servers: a rejected template lowers the quality rating of the WhatsApp account. If you are integrating for a client, that rating is theirs. A script that tries twenty variants of a template until one passes leaves the damage with someone who had no say in the decision.

Finding out when Meta decides

createTemplate returns immediately with PENDING. Meta decides later, and it usually takes a while. You can ask for the list again, or subscribe and be told:

await met.webhooks.subscriptions.create({
  url: 'https://your-server.com/webhooks/met',
  enabled_events: [
    'channel.template.approved',
    'channel.template.rejected',
    'channel.template.status_updated',
  ],
});
EventWhenWhat to do
channel.template.approvedMeta approved itYou can send it now.
channel.template.rejectedMeta rejected itCarries reason. Fix it and create it again under another name.
channel.template.status_updatedIt changed state for another reasonRead this one. See below.

The third one is the one not to ignore. Meta sends fourteen different states over that channel, and several — paused, disabled, flagged, limit exceeded — happen to templates that were already approved and working. Operationally that is worse than a rejection: you find a rejection when you go and look, but a pause cuts off sends that are already running, with nobody touching anything.

The event's data carries template_name, template_language, waba_id, Meta's raw status and, when there is one, the reason.

Sending it

Once approved, sending is the usual path:

await met.contacts.sendTemplate(contactId, {
  name: 'appointment_reminder',
  language: 'en',
  namespace: process.env.WA_TEMPLATE_NAMESPACE!,
  params: { '1': 'Ana', '2': 'Tuesday at 3' },
});

Deleting it

await met.channels.deleteTemplate(whatsapp.id, { name: 'appointment_reminder' });

It stops existing for all the numbers on that account.

And while we're here: quick replies

Quick replies — the saved answers your team drops into the chat — are in the API too, and they have nothing to do with Meta: they're your own text, inside the workspace.

const saved = await met.conversations.quickReplies();
await met.conversations.createQuickReply({ title: 'Hours', content: 'We are open 8 to 6.' });

Reading requires conversations:read; creating, editing and deleting require conversations:write.

What's next