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.
defining sending module @genuka/whatsapp/templatesmessages.template()shape { type: "HEADER", format: "TEXT", text: "Commande {{1}}" }{ type: "header", parameters: [{ type: "text", text: "A-42" }] }contains placeholders and examples filled parameters lifetime submitted once, reviewed, reused for months one 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:
| Rule | Failure |
|---|---|
Name is ^[a-z0-9_]+$, ≤ 512 chars | ValidationError |
| One HEADER / BODY / FOOTER / BUTTONS / CAROUSEL / LIMITED_TIME_OFFER each | ValidationError |
| Header text ≤ 60 chars, 1 parameter max | ValidationError |
| Body ≤ 1024 chars, footer ≤ 60 chars, no parameter in a footer | ValidationError |
| ≤ 10 buttons, ≤ 2 URL, ≤ 1 PHONE_NUMBER, ≤ 1 COPY_CODE, ≤ 1 OTP | ValidationError |
| Button labels are unique; quick replies are listed consecutively | ValidationError |
| A URL parameter is the suffix, and comes with an example | ValidationError |
Positional parameters run {{1}}…{{n}}, no gaps, no repeats | ValidationError |
Named parameters match ^[a-z0-9_]+$ | ValidationError |
| One example per parameter — Meta rejects a template it cannot render | ValidationError |
| No positional/named mixing, across the whole template | ValidationError |
parameter_format agrees with the components | ValidationError |
OTP buttons only on AUTHENTICATION | ValidationError |
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
Carousel
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".
validateCarouselrefuses 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: 600The 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 givencreate 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 buttonIt 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 templateCall 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:
- 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. readandclickedare 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
| Route | Notes |
|---|---|
GET /api/v1/templates | Filters 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/templates | Definition 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/library | search, topic, usecase, industry, language, limit, after. |
GET /api/v1/templates/{id}/analytics | Refreshes the archive, then serves the archive. meta.warning names the "analytics never enabled" case explicitly. |
POST /api/v1/templates/{id}/analytics | Enables analytics for the WABA. Not retroactive. |