Genuka WA docs

Templates

Building, validating and managing message templates: components, buttons, carousel, limited-time offer and authentication.

Build a template, submit it, follow its lifecycle, read its analytics.

The distinction that trips everyone. A template being defined and a template being sent are two different objects with the same vocabulary.

definingsending
module@genuka/whatsapp/templatesmessages.template()
shape{ type: "HEADER", format: "TEXT", text: "Commande {{1}}" }{ type: "header", parameters: [{ type: "text", text: "A-42" }] }
containsplaceholders and examplesfilled parameters
lifetimesubmitted once, reviewed, reused for monthsone message

Everything below is the left-hand column.


1. Building a template

import {
  defineTemplate, textHeader, body, footer, buttons,
  quickReply, urlButton, phoneButton,
} from "@genuka/whatsapp/templates";

const template = defineTemplate({
  name: "commande_expediee",          // lowercase, digits, underscores. Nothing else.
  language: "fr",
  category: "UTILITY",
  components: [
    textHeader({ text: "Commande {{1}}", examples: ["A-42"] }),
    body({ text: "Bonjour {{1}}, votre colis part demain.", examples: ["Ada"] }),
    footer("Répondre STOP pour vous désinscrire"),
    buttons([
      urlButton({ text: "Suivre", url: "https://genuka.com/t/{{1}}", example: "https://genuka.com/t/42" }),
      phoneButton({ text: "Appeler", phoneNumber: "+237699001122" }),
      quickReply("Merci"),
    ]),
  ],
});

defineTemplate returns the exact JSON POST /{WABA_ID}/message_templates accepts — store it, diff it, resubmit it, no translation layer.

What is checked before the network call

Everything Meta expresses as a number or a shape. The full list lives in limits.TEMPLATE and limits.TEMPLATE_DEFINITION; the ones that bite:

RuleFailure
Name is ^[a-z0-9_]+$, ≤ 512 charsValidationError
One HEADER / BODY / FOOTER / BUTTONS / CAROUSEL / LIMITED_TIME_OFFER eachValidationError
Header text ≤ 60 chars, 1 parameter maxValidationError
Body ≤ 1024 chars, footer ≤ 60 chars, no parameter in a footerValidationError
≤ 10 buttons, ≤ 2 URL, ≤ 1 PHONE_NUMBER, ≤ 1 COPY_CODE, ≤ 1 OTPValidationError
Button labels are unique; quick replies are listed consecutivelyValidationError
A URL parameter is the suffix, and comes with an exampleValidationError
Positional parameters run {{1}}…{{n}}, no gaps, no repeatsValidationError
Named parameters match ^[a-z0-9_]+$ValidationError
One example per parameter — Meta rejects a template it cannot renderValidationError
No positional/named mixing, across the whole templateValidationError
parameter_format agrees with the componentsValidationError
OTP buttons only on AUTHENTICATIONValidationError

What is not checked: anything a human reviewer judges — tone, whether the body reads as marketing, whether the URL domain belongs to the business. Those cannot be precomputed, and guessing would block legitimate templates.

validateTemplate(definition) runs the same checks on a definition that did not come from the builders — raw JSON from an API caller gets identical treatment.

Parameter style

Positional ({{1}}) or named ({{order_id}}), never both. defineTemplate infers parameter_format from the components; templateParameterStyle(components) reports it.

Media headers

mediaHeader("IMAGE", handle) takes a header_handle from the Resumable Upload API (POST /{APP_ID}/uploads → POST /{UPLOAD_ID}), not a media_id. A media_id belongs to a phone number and expires after 30 days; a handle belongs to the app and is baked into the approved template forever. Passing one where the other is expected is the most common creation failure.


2. Special templates

import { carouselTemplate, carouselCard, urlButton } from "@genuka/whatsapp/templates";

const promo = carouselTemplate({
  name: "soldes_juin",
  language: "fr",
  body: { text: "Nos meilleures offres" },
  cards: [
    carouselCard({
      header: { format: "IMAGE", handle: "4::aW..." },
      body: { text: "Sac en cuir, -30%" },
      buttons: [urlButton({ text: "Acheter", url: "https://genuka.com/p/1" })],
    }),
    carouselCard({
      header: { format: "IMAGE", handle: "4::bX..." },
      body: { text: "Ceinture assortie, -20%" },
      buttons: [urlButton({ text: "Acheter", url: "https://genuka.com/p/2" })],
    }),
  ],
});
  • ≤ 10 cards, ≥ 1 card.

  • At least one button per card (2026 rule) and at most two.

  • Card body ≤ 160 characters — not 1024.

  • Top level is BODY + CAROUSEL only: no header, no footer, no buttons.

  • Homogeneity. Every card must have the same media format and the same ordered list of button types. Meta's API accepts a heterogeneous carousel and the human reviewer rejects it days later with "cards must have the same structure". validateCarousel refuses it in microseconds instead:

    ValidationError: carousel.cards[1]: must match the structure of the first card
                     (expected IMAGE|URL, got IMAGE|QUICK_REPLY)

Limited-Time Offer

const lto = limitedTimeOfferTemplate({
  name: "lto_juin", language: "fr",
  offer: { text: "Expire dans" },              // ≤ 16 characters
  body: { text: "Profitez de -20% ce week-end" },
  buttons: [copyCodeButton("JUIN20"), urlButton({ text: "Boutique", url: "https://genuka.com" })],
});

MARKETING only, no footer, header must be IMAGE or VIDEO, and only COPY_CODE / URL buttons. has_expiration defaults to true, which shifts a responsibility to the send side:

components: [...otherComponents, limitedTimeOfferParameters(Date.now() + 48 * 3600_000)]

Forget it and the countdown never renders — the one thing the component exists for.

Coupon

The code appears twice: as a body variable and as the coupon_code button parameter. Both halves are enforced.

const coupon = couponTemplate({
  name: "coupon_juin", language: "fr",
  body: { text: "Utilisez le code {{1}} avant dimanche", examples: ["JUIN20"] },
  couponCode: "JUIN20",   // must be one of the body examples
});

// at send time, one argument feeds both places:
const components = couponSendComponents({ code: "JUIN20", bodyParameters: ["Ada", "JUIN20"] });

// or check a payload built elsewhere:
assertCouponCodeMatches(components);

assertCouponCodeMatches is the guard for payloads that did not come from couponSendComponents — it fails when the button says JUIN20 and the text says MAI10, which is exactly what happens when the two are passed separately.

Authentication

const otp = authenticationTemplate({
  name: "otp_login", language: "fr",
  otpType: "COPY_CODE",              // or ONE_TAP / ZERO_TAP
  // ONE_TAP and ZERO_TAP additionally need:
  // packageName, signatureHash, and for ZERO_TAP: zeroTapTermsAccepted: true
});
// → BODY { add_security_recommendation: true }
//   FOOTER { code_expiration_minutes: 10 }
//   BUTTONS [ OTP ]
//   message_send_ttl_seconds: 600

The body is Meta's, not yours: passing text is refused. TTL defaults to 600 s because an OTP arriving after its code expired is worse than one that never arrives — the user retries and burns a second code.

At send time the code also goes in two places; authenticationSendComponents(code) writes both.


3. Managing templates

import { MetaTransport } from "@genuka/whatsapp";
import { TemplateClient } from "@genuka/whatsapp/templates";

const client = new TemplateClient({
  transport: new MetaTransport({ accessToken }),
  wabaId,
});

await client.create(template);                    // validates, then POSTs
await client.create(template, { validate: false }); // escape hatch, see below
await client.list({ status: ["APPROVED", "PAUSED"] });
await client.get(templateId);
await client.update(templateId, { components });  // POST /{TEMPLATE_ID}
await client.remove({ name, templateId });        // one language when templateId is given

create validates first because a rejected submission is not free: it counts against the account's template limits and, repeated, against its quality rating. { validate: false } exists for the one case that matters — our validator is deliberately stricter than Graph's 400s, and a caller whose payload Meta already accepts must not be locked out by us.

Editing an approved template resubmits it. It returns to PENDING, and campaigns using it stop launching until review completes. Name and language are immutable.

Template library (US-3.4)

const page = await client.library({ topic: "ORDER_MANAGEMENT", industry: "E_COMMERCE", language: "fr" });
await client.createFromLibrary({
  name: "suivi_livraison",
  language: "fr",
  category: "UTILITY",
  library_template_name: page.data[0].name,
  library_template_button_inputs: [{ type: "URL", url: { base_url: "https://genuka.com/track" } }],
});

Library templates are approved instantly — Meta wrote the copy, we only supply the destinations. For a merchant who needs "your order has shipped" today, that is the difference between sending this afternoon and sending next week. createFromLibrary skips structural validation on purpose: we do not own the structure.


4. Lifecycle (US-3.5)

PAUSED and DISABLED arrive after approval, because recipients complained. A campaign that starts on a paused template does not fail fast — it fails once per recipient, burns the quota and leaves a trail of 132015s.

import { assertTemplateSendable } from "@genuka/whatsapp/templates";

assertTemplateSendable(template);   // throws WhatsAppError, errorClass "template"
isTemplateSendable(template);       // non-throwing, for greying out a button

It accepts our Prisma row (status: "approved") and Meta's resource (status: "APPROVED") alike, and reuses Meta's own codes — 132015 for paused, 132016 for disabled — so a caller's existing error handling reacts identically whether we caught it or Graph did.

WhatsAppError: Template "promo_juin" is paused by Meta after negative recipient feedback
               and cannot be sent. Wait for the pause to lift or edit the template

Call it once before the first send of a campaign, not once per recipient.


5. Analytics (US-3.6) — read this part

Two traps, both silent:

  1. Analytics are off by default, per WABA. Until client.enableAnalytics() is called, Meta answers with an empty data set and no error. You find out weeks later that nothing was ever collected — and nothing is backfilled.
  2. read and clicked are purged after 7 days. Since December 2025 Meta keeps nothing at all beyond a year. A click not written down within the week is gone.
await client.enableAnalytics();          // once per WABA, at connection time
const points = await client.analytics({
  templateIds: [providerId],             // ≤ 10 per call
  start: unixSeconds, end: unixSeconds,  // ≤ 90 days apart
});
const day = toAnalyticsDay(points[0]);   // { day: "2026-01-01", sent, delivered, read, clicked, cost, clicksByButton }

Granularity is DAILY and nothing else.

Our archive is the record; Meta is the feed. lib/analytics/templates.ts writes every fetch into AnalyticsSnapshot (kind: "template", subject: <template name>, one row per day, upsert on [companyId, kind, subject, day, granularity]). The merge takes the maximum of stored and incoming for every counter: re-reading a 10-day-old day returns read: 0, clicked: 0 — not because nothing happened, but because Meta dropped the counters — and a plain overwrite would destroy the only copy we have. Every metric is monotonic, so the maximum is both safe and correct.


6. Genuka WA API surface

RouteNotes
GET /api/v1/templatesFilters status, category, language, companyId. sync=true reconciles statuses from Meta (optionally narrowed by companyId / connectionId) and is the only path that stores a quality score — POST /api/v1/templates/sync is the same refresh on its own. Each row carries qualityScore.
POST /api/v1/templatesDefinition mode (components) or library mode (libraryTemplateName + libraryTemplateButtonInputs). validate: false bypasses our validator. Errors: 400 invalid_template, 502 meta_rejected, 409 template_exists.
GET /api/v1/templates/{id}Adds a best-effort live read from Meta: qualityScore, meta, and reconciles a drifted status. Graph being down never fails the read.
PATCH /api/v1/templates/{id}Edits components / category / messageSendTtlSeconds, revalidates the whole definition, and moves the row back to pending.
DELETE /api/v1/templates/{id}Deletes this language on Meta (best-effort) then the row.
GET /api/v1/templates/librarysearch, topic, usecase, industry, language, limit, after.
GET /api/v1/templates/{id}/analyticsRefreshes the archive, then serves the archive. meta.warning names the "analytics never enabled" case explicitly.
POST /api/v1/templates/{id}/analyticsEnables analytics for the WABA. Not retroactive.

On this page