# WhatsApp · Templates (Manage)

**Portal:** Manage · **Home:** `/manage/messages/templates` (nav → *Messages → Broadcasts → Templates*) · **Routes:** `manage.messages.templates.*` · **Command:** `whatsapp:sync-templates` (reconcile)

## What it does
Lets an admin **author a Meta WhatsApp Cloud message template** (header / body with `{{n}}` variables / footer / buttons), **preview it exactly as the customer will see it** (a WhatsApp chat bubble like Meta's own Template library), and **submit it to Meta for approval** — turning `whatsapp_templates` from a read-only sync mirror into a table that also holds locally-authored templates. Approved templates then power **broadcasts**, **funnel WhatsApp automation**, and the **out-of-window inbox composer**.

**Cloud API only** — a template is a Meta concept; the Bridge (QR) has none. The create form only offers Cloud channels, and the send/broadcast paths already hard-gate to `isCloudApi()`.

**Inbox rendering (2026-07-09).** A sent template message no longer shows just its name in the thread — `MessagePresenter` resolves the stored template's `components` and returns a `template` block with the **actual rendered content** (header text/image, body with its `{{n}}` variables substituted from the send's frozen params, footer, button labels), which `Inbox.vue` renders as a proper WhatsApp-style card. Template bubbles get a distinct **amber tint** (`#ffedcc`, alongside human-green / flow-blue / AI-purple) and a small **"Template" tag** by the time. The resolved template is memoised per request (a thread with many template sends doesn't re-query). Same pass fixed the failed-bubble error text to handle both `meta.error` shapes (a send-time string and a status-webhook `{code, title}` array — the latter used to display "Array").

**Scope:** a **TEXT or IMAGE header** (image added 2026-07-09), a **BODY** (with variables), a **FOOTER**, and **QUICK_REPLY + URL buttons**. Video/document headers and phone-number buttons remain out of scope (follow-ups). Params are **positional** (`{{1}}`, `{{2}}`) to match the existing send path — there is no named-parameter code anywhere yet.

**AUTHENTICATION (OTP login) template — one-click (2026-07-15).** Authentication templates are a **fixed shape** (no builder): Meta supplies the body (`{{1}} is your verification code.`), so authoring is just picking a Cloud channel. The Templates index shows a **"Phone-number login (WhatsApp OTP)"** card with a **"Create OTP login template"** button → `WhatsappTemplatesController@createOtpTemplate` (`POST templates/otp-template`) → `WhatsappRepository::submitAuthTemplate($channel)` submits the canonical definition: `category: AUTHENTICATION`, name `WhatsappTemplate::OTP_LOGIN_NAME` (`login_otp`), `en_US`, components = `BODY {add_security_recommendation:true}` + `FOOTER {code_expiration_minutes:5}` + `BUTTONS [{type:OTP, otp_type:COPY_CODE}]`. It persists SOURCE_LOCAL / PENDING (variables = 1, the code) and flips to APPROVED via the normal status webhook. **The gate:** `WhatsappTemplate::otpLoginReady()` = an APPROVED authentication template on a **usable Cloud (Meta Official) channel — active AND `STATUS_CONNECTED`** (shared `usableAuthTemplates()` scope; a Bridge/QR channel can't hold a Meta template, so it never satisfies the gate). `otpLoginTemplate()` resolves the channel+template that should send the code (prefers `login_otp`). **Channel disconnect / removal is handled automatically:** deleting the Meta channel also deletes its templates (`deleteChannel` hard-deletes `whatsapp_templates` for the channel), and deactivating (`is_active=false`) or disconnecting (`status != CONNECTED`) drops the channel out of the gate — so phone-OTP login turns OFF the moment the sending channel is gone, no orphaned "ready" state. Locked by `OtpTemplateTest` (create / gate / **disconnect-or-deactivate** / bridge-ignored).

**Editability (Meta's hard limits).** AUTHENTICATION templates are **not free-form** — Meta OWNS the content: the body is always "`{{1}} is your verification code.`", so a custom body / header is impossible (a custom-text auth template is a Meta 400). Only three things are adjustable: `add_security_recommendation` (the security line), `code_expiration_minutes` (the footer), and the OTP button (`otp_type` + label). For a fully editable message (custom wording, a business name like "Your PropertyLab code is {{1}}", header/footer/buttons) an admin authors a **UTILITY/MARKETING** template with the normal builder instead — but it loses the one-tap copy button and the authentication category. `TemplatePreview.vue` renders auth templates by synthesizing Meta's body/footer/copy-code button so the **View** page still shows the real OTP message. Also: authentication templates require a **verified Meta business** (stricter than Utility) — a create on an unverified WABA returns *"does not have permission to create message template"* (surfaced as a flash error).

**IMAGE header (how it works end-to-end).** Authoring: the form's header type gains *Image* — the admin uploads a **sample JPEG/PNG ≤5 MB** (stored as a shared `Media`, kept on `whatsapp_templates.header_media_id` for the in-app preview + edit); submission uploads that sample through Meta's **Resumable Upload API** (`POST /{APP_ID}/uploads` → `POST /{session}` with an `OAuth` auth header, `CloudApiDriver::uploadTemplateExampleMedia`) and puts the returned handle in `example.header_handle` — **`WHATSAPP_APP_ID` must be set in `.env`** (the app `WHATSAPP_APP_SECRET` belongs to) or the *submit* is rejected with a clear error. (`WHATSAPP_APP_ID` is only needed for **authoring** — *sending* uses the regular `/{phone}/media` upload with just the access token.)

**Sending an image-header template (the reliable path).** Meta REQUIRES an image parameter on EVERY send of an image-header template — a send without it fails with **error 132012 "Parameter format does not match format in the created template"**. `CloudApiDriver::sendTemplate` guarantees it at the single choke point (`resolveTemplateImageHeader`) with this resolution order: an already-resolved `media_id`/`link` (broadcast pre-upload) → a per-send stored image (`media_uuid` — the inbox composer's attached image) → **the template's OWN approved sample image (`header_media_id`)**. That last fallback is what makes image templates "just work": a broadcast or composer send that didn't attach a per-send image still goes out with the sample Meta already approved, instead of failing. So the per-send image is **optional** everywhere — the **broadcast compose** and the **inbox composer** both show it as *optional* ("blank = the template's image is used"); attaching one overrides the sample and (for the composer) shows the real picture in the thread. Broadcasts pre-upload the resolved image once (`WhatsappBroadcastRepository::prepareHeaderMedia`, sample fallback included) so every recipient reuses one Graph media id. Editing an image template without re-uploading reuses the stored sample (a fresh handle is regenerated from it).

**Template variables in the inbox composer (2026-09-13).** Picking a template in the out-of-window composer (`Inbox.vue`, `#composer-template`) opens one input for each variable it declares: each DISTINCT body `{{n}}` (`#template-param-N`), a header-text variable (`#template-header-param`) and each dynamic URL-button suffix (`#template-button-param-N`, N = the button's index). The slots come from `InboxController` shipping each template option's `components`, parsed by the shared `utils/templateComponents.js` — the same parser the flow template picker uses. Send stays disabled until every input has a value. `MessagesController@store` checks the values again against the template row (`countDistinctVariables` / `hasHeaderTextVariable` / `urlButtonVariableIndexes`): a blank or missing value is refused (flash, or a 422 over JSON) before anything is queued. This deliberately differs from the automated senders, which put `-` in a blank slot — a human is typing here, and a customer must never receive "Hi -,". Each value is trimmed and its whitespace runs collapsed (Meta rejects newlines and tabs in a parameter), then frozen on `meta.template` as `parameters` / `header {type: text}` / `buttons [{sub_type: url, index, text}]` — the shape `CloudApiDriver::sendTemplate` sends and `MessagePresenter` substitutes into the thread bubble. A text `header` is written only for a header-text variable, so an IMAGE-header send still resolves its image. Locked by `InboxTemplateVariablesTest`.

> **Historical gotcha (fixed 2026-07-11):** before the sample fallback, a broadcast that picked an image template but did not upload a per-send image sent with NO header component → every recipient failed 132012. The fix: an image-header template always resolves to *some* valid image (per-send or the approved sample).

**Admins do NOT pick the category.** Choosing Utility vs Marketing confuses non-experts, and Meta auto-assigns it anyway: the form always submits **UTILITY** (set in the controller), and Meta auto-upgrades to MARKETING when the content is promotional (it only reclassifies **one way** — UTILITY→MARKETING, never the reverse — so genuine transactional templates stay the cheaper Utility). `allow_category_change` is **not** sent (Meta removed it 2025-04-09; auto-reclassification is now the default). The create response returns the *submitted* category, so the authoritative final category arrives later via the **`template_category_update`** webhook (or a manual *Sync from Meta*).

**Admins also don't pick a language.** A Malaysian audience mixes languages in one message, and Meta's `language` field is only a label (it doesn't enforce the content language), so the form fixes it to **`en_US`** (mixed EN / BM / Chinese content is accepted). Language, like category, is therefore not a form control.

## How it works

### Data model (extends the existing table)
`WhatsappTemplate` gains authoring columns (migration `2026_07_02_000010_add_template_authoring_columns`) on top of the sync mirror:
- **`source`** (`SOURCE_META` = pulled by the sync / `SOURCE_LOCAL` = authored here) + a `SOURCES` metadata array.
- **`meta_template_id`** — the id Meta returns on create (kept for reference; the status webhook still matches by name + language).
- **`rejected_reason`** — Meta's free-form reason string, stored on a REJECTED webhook.
- **blame** (`created_by` / `updated_by` via `RecordsBlame`) — which admin authored a submission.

The natural key stays **`UNIQUE(channel_id, name, language)`** — a local submit and the Meta sync upsert the same row, so a template is never duplicated. `components` still stores the **raw Meta component array verbatim**, so the same shape feeds both the send path and the preview.

### Author → submit (create path)
`WhatsappTemplatesController@store` (`StoreRequest`) → `WhatsappRepository::submitTemplate($channel, $input)`:
1. **`buildTemplateDefinition()`** turns the structured builder form into the Graph `components` — HEADER (`format:TEXT` + `example.header_text`), BODY (`example.body_text` = **array-of-arrays**, one example per `{{n}}`), FOOTER, BUTTONS (`QUICK_REPLY` / `URL` + a URL `example` when the link is dynamic). `parameter_format` is **omitted** (positional is Meta's default).
2. **`CloudApiDriver::createTemplate()`** POSTs it to `POST /{WABA_ID}/message_templates` — a copy of the proven `subscribeApp()` WABA-scoped POST + error guard, reusing `client()` (Bearer + CA bundle) and `url()` (base + `v25.0`). The POST runs **outside** `DB::transaction` (external I/O must never hold a transaction open).
3. The row is persisted trusting **Meta's response**, not our input: `status` = `statusFromMeta(response.status)` (PENDING), `category` = `categoryFromMeta(response.category)` — **Meta may reclassify** a promo-worded UTILITY as MARKETING and approve it as such (billing impact), so the response is authoritative — plus `meta_template_id`, `source = SOURCE_LOCAL`, and `variables` counted from the body.

A **Graph rejection** (`RuntimeException` from the driver) is caught in the controller and re-thrown as a **`ValidationException` on `submit`**, so the modal stays open with the admin's input and shows Meta's reason.

### Approval + category → sync back (webhooks)
Two real-time webhooks keep a submitted row current:
- **`message_template_status_update`** — the existing chain: `CloudApiDriver::parseTemplateStatusUpdates` → `WhatsappRepository::updateTemplateStatus` (matched by channel + name + language), which now also stores **`rejected_reason`** (threaded from `TemplateStatusUpdate::$reason`; cleared on re-approval). A `PENDING` row auto-flips to APPROVED / REJECTED.
- **`template_category_update`** — new: `CloudApiDriver::parseTemplateCategoryUpdates` → `ProcessInboundWhatsAppWebhook::processTemplateCategoryUpdates` → `WhatsappRepository::updateTemplateCategory`, so when Meta reassigns the category (UTILITY→MARKETING) the **final category** is written back automatically. The `WhatsappDriver` interface carries the parser (Cloud implements it; Bridge + Sandbox return `[]`).

The **"Sync from Meta"** button (`@sync`) re-pulls every Cloud channel's templates via `syncTemplates` + `storeTemplates` as the reconciliation fallback for a missed webhook (and now **backfills `meta_template_id`** on synced rows so they become editable). **Webhook prerequisite:** the app's webhook subscription must include the `message_template_status_update` **and** `template_category_update` fields, and `WHATSAPP_APP_SECRET` must be the real value (signature verification) — otherwise rely on the Sync button. **No manual polling needed** in the UI: the index + Show pages auto-refresh every ~20s while any template is PENDING, so a webhook approval / category change flips the row live.

### Editing + resubmit
Only **APPROVED / REJECTED / PAUSED** templates that Meta already knows (have a `meta_template_id`) are editable — never a PENDING (in-review) one. Editing is a **`POST /{TEMPLATE_ID}`** (`CloudApiDriver::editTemplate`) with only the new **`components`** (**name / language are immutable**, and category stays Meta's — neither is sent); `WhatsappRepository::editTemplate` POSTs outside the transaction, then resets the row to **PENDING** and clears `rejected_reason` (Meta re-reviews; **already-sent messages are unaffected**). This is the recovery path for a REJECTED template (fix the content → resubmit) and the way to revise an APPROVED one. The **Edit** button lives on the **Show** page (which carries the full `components`); the same `TemplateFormModal` re-opens in `edit` mode, **reverse-mapping** the stored components back into the builder (name + channel shown read-only). A template whose components fall outside the v1 builder (a media header, or phone / copy-code buttons) is **not** editable in-app — a note points the admin to WhatsApp Manager — so editing never silently drops content. Meta doesn't publicly document an edit rate limit; an over-limit edit surfaces as the usual form error.

### Category: escaping MARKETING (error 131049)
Meta **auto-assigns** the category from the content — `StoreRequest` deliberately lets it, and in practice almost everything authored here comes back **MARKETING**. That is not cosmetic: a MARKETING template is subject to Meta's **per-user marketing message limit**, so a genuine session reminder fails for recipients who have already had their fill of marketing that week, with **`131049` — "This message was not delivered to maintain healthy ecosystem engagement"**. UTILITY templates are exempt. A template qualifies as UTILITY only if it is *non-promotional* AND *specific to something the user requested* — so a reminder must state time, place and link and nothing else; one price or "free spot" line pushes it back to MARKETING.

**The composer already asks for UTILITY on every create** (`mapInput`: `'category' => $request->input('category') ?: 'UTILITY'`) — asking is not the problem. Meta reads the CONTENT and overrules, and **submit time is the only moment anyone is in control**, since an approved Marketing template can never be moved by API. So three things sit at that moment:

- **A live Utility check in the composer** (`resources/js/utils/templateCategory.js`, unit-tested) scans the header + body + footer + **button labels** for the wording that forces a Marketing classification — a price (`RM97`, `原价`, `50%`), "free" / `免费`, scarcity (`限时`, `名额`, *hurry*), reward framing (`福利`, *bonus*, *lucky*), exclusivity and sales CTAs. The panel turns amber and names what it found; clean copy gets a green "reads as Utility". The word list is deliberately **tight** — a check that cries wolf gets ignored, and then the real warning does too.
- **The store flash calls out an override**: when Meta returns MARKETING for a template we submitted as Utility, that is a warning, not a success, and it says the category can no longer be changed by API.
- **`correct_category`** (below) catches the case where Meta re-decides *later*.

Two further things exist to deal with it after the fact:

- **`whatsapp_templates.correct_category`** — Meta's OWN verdict on what the template should be. It is omitted from the Graph default field set, so `syncTemplates` now asks for it explicitly (`fields=id,name,language,status,category,correct_category,components,rejected_reason` — every field `storeTemplates` consumes must be listed, or requesting fields drops them). Where it disagrees with `category`, the index row shows an amber **"Meta says Utility"** hint: the earliest warning that sends are about to start failing.
- **`POST templates/{id}/category`** (`@category`, `CategoryRequest` → `WhatsappRepository::changeTemplateCategory` → `CloudApiDriver::setTemplateCategory`, a `POST /{TEMPLATE_ID}` carrying only `category`). The row goes PENDING, since Meta re-reviews.

**The hard limit, and it is Meta's:** *"You cannot edit the category of an approved template."* Only **REJECTED / PAUSED** templates can be re-categorised by API (those may be edited without limit; an APPROVED one is capped at 10 edits per 30 days / 1 per 24h). An approved MARKETING template can only be appealed in **WhatsApp Manager → Message Templates → Template Category Updates**, within **60 days**, and that flow has **no API at all**. So `@category` refuses the approved case up front with that explanation rather than forwarding an opaque Graph error, and the UI only offers the button where it can work (`can_recategorise`). The practical alternative for an approved one is delete + re-author with utility-safe wording.

### Deleting (at Meta too)
**`DELETE templates/{id}`** (`@destroy` → `WhatsappRepository::deleteTemplate` → `CloudApiDriver::deleteTemplate`, `DELETE /{WABA_ID}/message_templates?hsm_id=…&name=…`). **Meta first, then the local row** — a local delete while the template still exists at Meta hides a row that is still holding its name, and the next sync would pull it straight back, so a Graph failure aborts the whole thing and the two stay in step. `hsm_id` **and** `name` are sent together to delete **this language only**; the name alone would take every language of that name with it. Two irreversible consequences the confirm dialog states: an approved template's **name cannot be reused for 30 days**, and any message already queued against it enters `PENDING_DELETION` while WhatsApp retries delivery for up to 30 days. Covered by `tests/Feature/Whatsapp/TemplateCategoryAndDeleteTest.php`.

### Validation (so we never eat a Meta 400)
`StoreRequest` enforces every rule Meta checks at creation: a **lowercase/underscore name**; header ≤ 60 chars & **≤ 1 variable** (with a required example); body ≤ 1024 with **sequential** `{{1}}..{{n}}` placeholders and **one example per variable**; footer ≤ 60 & **no variables**; ≤ 10 buttons, label ≤ 25; a URL button's dynamic `{{1}}` needs an example; and the channel must be **Cloud**.

### Admin UI (§14)
- **Index** (`Templates/Index.vue`) — the shared DataTable + FilterDrawer + chips (category / status / source / channel), **"New template"** (create modal) + **"Sync from Meta"**. Local + synced templates share one list. Row actions: **View**, **Make it Utility** (only when `can_recategorise`), **Edit** (only when `can_edit` — the row carries `components` so the same modal opens without a round-trip; the header image is not signed per row, and `@update` reuses the stored media when no new file is chosen, so it survives an edit made from here), and **Delete** behind a `ConfirmModal` spelling out the 30-day name lock.
- **Create / edit modal** (`Partials/TemplateFormModal.vue`, `mode: 'create' | 'edit'`) — owns the channel pick, the Inertia form + submission, and edit-mode hydration; the **two-column builder itself lives in the shared `Components/Whatsapp/TemplateDesigner.vue`** (also used by the funnel WhatsApp rule modal's inline "create template" panel): **left** = fields (name, header, body + per-variable example inputs, footer, a buttons repeater) + an info note that **Meta sets the category automatically** (no category / language pickers), **right** = a **live** `TemplatePreview`. In **edit** mode the modal hydrates from the row's components (name + channel read-only) and PUTs to `…/templates/{uuid}`. Nested `{{ }}` tokens are built from JS constants so the Vue parser never sees a literal `{{n}}`.
- **Preview** (`Components/Whatsapp/TemplatePreview.vue`) — renders a Meta component array as a WhatsApp chat bubble (header bold, body with `{{n}}` highlighted like Meta's library, muted footer + time, button rows with per-type icons). Reused by the modal and the Show page.
- **Show** (`Templates/Show.vue`) — identity card (name, status badge, category, a REJECTED reason banner) + the preview + a details panel and a collapsible raw-components view. Smart back-URL via `ResolvesBackUrl`. An **Edit** button (Approved / Rejected / Paused) re-opens the modal in edit mode; **Make it Utility** and **Delete** sit beside it; the page auto-refreshes while PENDING.

## GUIDELINES alignment
- **§2/§3** — the write goes through `WhatsappRepository::submitTemplate` (the DB upsert in `DB::transaction`, the Graph POST outside it); a thin `WhatsappTemplatesController` with **explicit** input mapping (`mapInput` → `$data['whatsapp_template'][…]`, never `$request->validated()`); validation in `StoreRequest`; filtering in `TemplateQueryRequest` (§9/§14).
- **§3 constants** — `SOURCE_*` / `SOURCES` join the existing `CATEGORY_*` / `STATUS_*`; no magic strings (Meta's category enum is mapped via `categoryFromMeta`).
- **§7** — a new migration only (never edit the committed create); snake_case ≤ 30-char columns, indexed, no schema FKs; blame via `RecordsBlame`.
- **§13/§14** — Inertia + Vue 3 + Tailwind; the shared DataTable / FilterDrawer / Modal / composable; a dedicated Show page; no `create` / `{id}/edit` routes.

## Related files

**Backend**
- [src/Whatsapp/WhatsappTemplate.php](/src/Whatsapp/WhatsappTemplate.php) — `SOURCE_*` / `SOURCES` + `meta_template_id` / `rejected_reason` + `RecordsBlame`; the Meta→constant mappers + `countVariables()`.
- [src/Whatsapp/Drivers/CloudApiDriver.php](/src/Whatsapp/Drivers/CloudApiDriver.php) — `createTemplate()` (POST `/{WABA_ID}/message_templates`) + `editTemplate()` (POST `/{TEMPLATE_ID}`) + `parseTemplateCategoryUpdates()`, alongside the read-only `syncTemplates()` + `parseTemplateStatusUpdates()`. (The `parseTemplateCategoryUpdates` contract is on [WhatsappDriver](/src/Whatsapp/Drivers/WhatsappDriver.php); Bridge + Sandbox return `[]`.)
- [src/Whatsapp/Repositories/WhatsappRepository.php](/src/Whatsapp/Repositories/WhatsappRepository.php) — `submitTemplate()` + `submitAuthTemplate()` (one-click OTP login template) + `editTemplate()` (resubmit → PENDING) + `buildTemplateDefinition()`; `updateTemplateStatus()` (stores `rejected_reason`) + `updateTemplateCategory()`; `storeTemplates()` (sync, backfills `meta_template_id`).
- [src/Whatsapp/WhatsappTemplate.php](/src/Whatsapp/WhatsappTemplate.php) — `OTP_LOGIN_NAME` + `otpLoginReady()` / `otpLoginTemplate()` (the phone-OTP-login gate) alongside the category/status/source constants + Meta mappers.
- [app/Http/Controllers/Manage/Whatsapp/WhatsappTemplatesController.php](/app/Http/Controllers/Manage/Whatsapp/WhatsappTemplatesController.php) — index (+ `otpLogin` state) / store (defaults category to UTILITY, catches Meta rejection **and** ConnectionException) / **createOtpTemplate** (one-click auth template) / update (edit — gated to Approved/Rejected/Paused) / show / sync.
- [app/Http/Requests/Manage/Whatsapp/Templates/StoreRequest.php](/app/Http/Requests/Manage/Whatsapp/Templates/StoreRequest.php) (category **nullable** — the controller defaults it) (+ [UpdateRequest.php](/app/Http/Requests/Manage/Whatsapp/Templates/UpdateRequest.php) extends it, + [TemplateQueryRequest.php](/app/Http/Requests/Manage/Whatsapp/Templates/TemplateQueryRequest.php)).
- [app/Jobs/Whatsapp/ProcessInboundWhatsAppWebhook.php](/app/Jobs/Whatsapp/ProcessInboundWhatsAppWebhook.php) — the `message_template_status_update` consumer (passes the reason) + `processTemplateCategoryUpdates` (writes back Meta's reassigned category).

**Frontend (Vue)**
- [resources/js/Components/Whatsapp/TemplateDesigner.vue](/resources/js/Components/Whatsapp/TemplateDesigner.vue) — the shared two-column builder (fields + live preview), consumed by the modal here AND the funnel WhatsApp rule modal.
- [resources/js/Components/Whatsapp/TemplatePreview.vue](/resources/js/Components/Whatsapp/TemplatePreview.vue) — the WhatsApp-bubble preview (reused by the designer + Show).
- [resources/js/Pages/Manage/Messages/Templates/Index.vue](/resources/js/Pages/Manage/Messages/Templates/Index.vue) · [Partials/TemplateFormModal.vue](/resources/js/Pages/Manage/Messages/Templates/Partials/TemplateFormModal.vue) · [Show.vue](/resources/js/Pages/Manage/Messages/Templates/Show.vue).
- [resources/js/Components/Messages/MessagesTabs.vue](/resources/js/Components/Messages/MessagesTabs.vue) — the "Templates" tab (a Broadcasts pill on the Messages hub strip; the old ManageLayout nav entries are gone).

**Migration + tests**
- [database/migrations/2026_07_02_000010_add_template_authoring_columns.php](/database/migrations/2026_07_02_000010_add_template_authoring_columns.php).
- [tests/Unit/Whatsapp/TemplateDefinitionTest.php](/tests/Unit/Whatsapp/TemplateDefinitionTest.php) — the Meta payload builder (positional + examples shape, DB-free).
- [tests/Unit/Whatsapp/TemplateCategoryWebhookTest.php](/tests/Unit/Whatsapp/TemplateCategoryWebhookTest.php) — the `template_category_update` parser (DB-free).
- [tests/Feature/Whatsapp/SubmitTemplateTest.php](/tests/Feature/Whatsapp/SubmitTemplateTest.php) — submit + persist trusting Meta's returned category, and **edit → resubmit** (components-only POST → PENDING) (mocked driver).

**Related handbooks**
- [Broadcast](/docs/modules_handbook/manage/messages/whatsapp/broadcast.md) + [Flow](/docs/modules_handbook/manage/messages/whatsapp/flow.md) + [Funnel WhatsApp automation](/docs/modules_handbook/manage/events/funnel-whatsapp/readMe.md) (all consume approved templates) · [Settings](/docs/modules_handbook/manage/messages/whatsapp/settings.md).
