DEV Community

Cover image for From WhatsApp Templates to Campaigns: What Happens Between "Approved" and "Delivered"
WAPGROWAI for WAPGROWAI

Posted on

From WhatsApp Templates to Campaigns: What Happens Between "Approved" and "Delivered"

Sending one WhatsApp template from a script takes ten minutes. Turning approved templates into campaigns a business can run every week — to thousands of people, with buttons that do something, without tripping Meta's limits, and with numbers the team can trust — is a different job.

This post follows a template through that whole path, using the pipeline in WAPGROWAI, a WhatsApp Business Platform, as the worked example: building the template, getting it approved, making its buttons actionable, fanning a campaign out, retrying the right failures, and reading what happens after "sent". The snippets are small TypeScript, and each was run against the cases it describes.

1. Building a template Meta will approve

A template is a message you register with Meta in advance, so you can send it to people who haven't written to you in the last 24 hours. Every template has a category, and the category decides both the rules and the price:

  • Marketing: offers, announcements, re-engagement.
  • Utility: updates about something the customer did — an order, a booking, an account change.
  • Authentication: one-time passcodes.

Meta can change your category. Its categorisation process reviews approved templates and moves ones that read as promotional from utility to marketing — normally with a day's notice, sometimes immediately — and sends a template_category_update webhook when it does. A cheaper utility template quietly becoming a marketing one is a pricing change, so it's worth surfacing in your UI.

Templates are created with POST /{WABA_ID}/message_templates:

{
  "name": "order_shipped",
  "language": "en",
  "category": "UTILITY",
  "components": [
    {
      "type": "BODY",
      "text": "Hi {{1}}, your order {{2}} is on its way.",
      "example": { "body_text": [["Asha", "#1042"]] }
    },
    {
      "type": "BUTTONS",
      "buttons": [
        { "type": "QUICK_REPLY", "text": "Track my order" },
        { "type": "QUICK_REPLY", "text": "Talk to someone" }
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Three details that decide whether a template gets through:

  • Variables need examples. Meta requires an example value for every parameter. Templates can use positional ({{1}}) or named parameters. WAPGROWAI lets people write named variables in its template builder, converts them to positional ones on submission, and fills in an example for each so the review has something to read.
  • Media headers are uploaded, not linked. For an image, video or document header, the template API expects a media handle from Meta's upload API rather than a URL — so the builder uploads the file first and submits the handle.
  • Buttons have hard limits. Up to 10 buttons in total; at most two URL buttons, one phone-number button and one copy-code button; labels up to 25 characters; and if you mix quick replies with other button types, the quick replies must sit together as one group (components reference).

2. Approval

A new template is PENDING until Meta reviews it, then APPROVED or REJECTED. Approved templates can later be PAUSED or disabled if recipients repeatedly block or report them — so "approved" is a state you keep re-checking, not a one-time event.

Meta reports changes through the message_template_status_update webhook and through the template list endpoint (GET /{WABA_ID}/message_templates). WAPGROWAI reads the list when you sync, which brings back new statuses, Meta's category, and templates created or deleted directly in Meta's own tools. Sending a template that isn't approved fails at Meta's end with 132001, so approval status is the first thing to check when a campaign won't go out.

3. Making buttons do something

A quick-reply button is just a label until you decide what a tap means. The robust way to attach behaviour is the button's payload: a value you set at send time, which Meta echoes back in the webhook when the customer taps it.

WAPGROWAI generates an opaque payload per button — tba_ plus a UUID — and stores the action against it: start a chatbot, send a message, or send another template. At send time, each quick-reply button gets its payload by index:

// send-template.ts
const GRAPH = `https://graph.facebook.com/${process.env.GRAPH_API_VERSION ?? "v24.0"}`;

type Recipient = { phone: string; variables: string[] };

export const buildTemplateMessage = (recipient: Recipient, buttonPayloads: string[]) => ({
  messaging_product: "whatsapp",
  to: recipient.phone,
  type: "template",
  template: {
    name: "order_shipped",
    language: { code: "en" },
    components: [
      {
        type: "body",
        parameters: recipient.variables.map((text) => ({ type: "text", text })),
      },
      ...buttonPayloads.map((payload, index) => ({
        type: "button",
        sub_type: "quick_reply",
        index: String(index),
        parameters: [{ type: "payload", payload }],
      })),
    ],
  },
});

export const sendTemplate = async (phoneNumberId: string, token: string, message: object) => {
  const res = await fetch(`${GRAPH}/${phoneNumberId}/messages`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify(message),
  });
  const data = await res.json();
  if (!res.ok) throw Object.assign(new Error(data.error?.message ?? "send failed"), { code: data.error?.code });
  return data.messages[0].id as string; // the wamid every later status webhook refers to
};
Enter fullscreen mode Exit fullscreen mode

When the customer taps, the webhook arrives as a message of type: "button", carrying the payload and the label:

{ "type": "button", "button": { "payload": "tba_9f2c1e7a…", "text": "Track my order" } }
Enter fullscreen mode Exit fullscreen mode

Match on the payload — never on the label:

// button-reply.ts
type InboundMessage = { type: string; button?: { payload: string; text: string } };

// Match on the payload we generated, never on the label the customer sees.
export const findButtonAction = <A>(message: InboundMessage, actionsByPayload: Map<string, A>): A | null => {
  if (message.type !== "button" || !message.button?.payload.startsWith("tba_")) return null;
  return actionsByPayload.get(message.button.payload) ?? null;
};
Enter fullscreen mode Exit fullscreen mode

If you match on text instead, a customer who simply types "Track my order" triggers the action, and renaming a button silently breaks every campaign already sent with it. In WAPGROWAI, a matched button action also takes precedence over keyword rules for that message, so one tap never fires two automations.

4. Fanning the campaign out

A campaign is an approved template, the values for its variables, and an audience — a segment, a hand-picked list or an uploaded file. Contacts who opted out (by replying STOP or UNSUBSCRIBE, for example) are removed while the audience is built, before anything is queued.

Then comes throughput. Cloud API allows 80 messages per second per number by default, raised automatically to 1,000 for eligible high-volume numbers. Sending faster than your level fails with error 130429.

WAPGROWAI gives each campaign its own queue and splits recipients into chunks of 70, released one second apart — under the default ceiling, with headroom for the inbox and automations sending at the same time:

// chunks.ts
export const chunk = <T>(items: T[], size: number): T[][] =>
  Array.from({ length: Math.ceil(items.length / size) }, (_, i) => items.slice(i * size, (i + 1) * size));

// One job per chunk, each released a second after the previous one.
// `x-delay` needs RabbitMQ's delayed-message exchange plugin.
export const scheduleChunks = <T>(recipients: T[], publish: (batch: T[], delayMs: number) => void, size = 70) =>
  chunk(recipients, size).forEach((batch, i) => publish(batch, i * 1000));
Enter fullscreen mode Exit fullscreen mode

Separate queues per campaign mean one large send doesn't hold up another, and a finished campaign's queue is simply deleted. Recipients inside a chunk are sent concurrently.

Two more details from production. Each send returns a message ID (wamid), which is stored against the recipient — every later status webhook refers to it. And for media headers, the file is uploaded to /{PHONE_NUMBER_ID}/media and sent by ID rather than by link: if Meta can't fetch a linked file, it can drop the media without failing the send.

Separately, Meta's messaging limits cap how many unique people your business portfolio can message outside a customer service window in a moving 24 hours — 250 for a new portfolio, rising through 2,000, 10,000 and 100,000 to unlimited as quality stays high. WAPGROWAI shows the current tier on the campaign screen and caps hand-picked audiences at it.

5. Retrying the right failures

A send can fail synchronously, and the error code tells you whether trying again makes sense:

Code Meaning Retry?
130429 Throughput limit reached Yes, with backoff
131056 Too many messages to the same person in a short time Yes, after a wait
131049 Not delivered "to maintain healthy ecosystem engagement" No — Meta says wait at least 24 hours
131047 24-hour window closed, free-form message sent No — send a template
131050 Recipient stopped marketing messages No
132000 / 132001 Parameter count mismatch / template not found or not approved No — fix the request

Codes and guidance are from Meta's error codes reference. WAPGROWAI retries only the first two (plus network-level failures) — up to three times, after 1, 2 and 4 seconds — and records everything else as failed with Meta's reason:

// retry.ts
// Throughput limits: worth retrying. Everything else is permanent for this send.
const TRANSIENT = new Set([130429, 131056]);

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export const sendWithRetry = async <T>(send: () => Promise<T>, maxRetries = 3, baseMs = 1000): Promise<T> => {
  for (let attempt = 0; ; attempt++) {
    try {
      return await send();
    } catch (error) {
      const code = (error as { code?: number }).code;
      if (!TRANSIENT.has(code as number) || attempt === maxRetries) throw error;
      await sleep(baseMs * 2 ** attempt); // 1s, 2s, 4s
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

6. After "sent"

A successful API call only means Meta accepted the message. What happens next arrives as status webhooks: sent, delivered, read (played for voice messages) — or failed, sometimes seconds after a successful send. 131049 is the common example: the send succeeds, then a failed status arrives with an error_data.details string explaining why. That string is the one worth showing a person, so store it with the message.

Statuses don't arrive in a guaranteed order, and Meta notes that a message read in an open chat can go straight to read with no separate delivered. The previous post in this series covers keeping statuses from moving backwards and handling duplicates.

One number isn't a status at all: replied. Rather than marking recipients, WAPGROWAI computes it when the report is read — a recipient has replied if their conversation has an inbound message at or after the campaign reached them:

SELECT COUNT(DISTINCT cc.contact_id) AS replied
FROM campaign_contacts cc
JOIN messages m
  ON m.conversation_id = cc.conversation_id
 AND m.direction = 'inbound'
 AND m.created_at >= cc.sent_at
WHERE cc.campaign_id = :campaign AND cc.sent_at IS NOT NULL;
Enter fullscreen mode Exit fullscreen mode

Computing it keeps "replied" correct no matter when the reply arrives, at the cost of a join you'll want indexed on conversation_id. And because a reply is a customer message, it also opens the 24-hour customer service window — the campaign turns into a conversation your team can answer freely in the inbox.

What this adds up to

Each step on its own is simple. Together they decide whether a campaign is something a business can run routinely or something an engineer has to watch:

  • templates built to pass review, and kept under watch after approval;
  • buttons that trigger actions reliably, because they are matched on payloads;
  • sends paced under Meta's limits, with only transient errors retried;
  • reporting built on message IDs, status webhooks and real replies.

In WAPGROWAI all of this sits behind the Campaigns screens — template builder, audience step, schedule, and a per-recipient report — and the campaign guide walks through it without the internals.


I work on WAPGROWAI; the pipeline above is the one it runs. If you're integrating WhatsApp into your own product, see WAPGROWAI for developers.

Top comments (0)