Date: 2026-07-17 Branch: poc-whats Status: Design approved, pending spec review
When a user submits a WhatsApp template that violates a Meta structural rule (e.g. a
variable at the start or end of the body — Graph API code: 100, error_subcode: 2388299),
two things go wrong:
- The useful message is hidden. Meta returns a generic
message: "Invalid parameter"plus the human-readableerror_user_title("Parâmetros iniciais ou finais não permitidos") anderror_user_msg("As variáveis não podem estar no início ou no fim do modelo."). Today the backend throws with the genericmessage, so the user only sees "Invalid parameter". - The error only surfaces after a round-trip to Meta. The user submits, waits, and only then learns the template is invalid — for rules we can check locally before submitting.
- Surface Meta's real, human-readable message on any server-side template rejection.
- For the common structural rules, block submission client-side and show the error before hitting Meta, with a clear inline + banner treatment.
- No changes to send-time template dispatch, campaign, or flow logic.
- No changes to URL/COPY_CODE button variable handling (already handled — a URL button's
variable must be trailing, the opposite rule; see
template-placeholders.ts). - No new template statuses, endpoints, or schema/migration changes.
- Rule set: broad — leading/trailing variable, adjacent variables, non-sequential numbering, missing variable examples.
- Target: body and header TEXT.
- UI: inline error under the field and a summary banner at the top; Save disabled while any issue exists.
- Backend message: compose
error_user_title+error_user_msgserver-side (so the toast, DLQ logs, and recipient logs all get the readable message).
Four independent pieces.
File: worker/libs/whatsapp.ts (handleResponse, lines ~357-440) and the GraphResponse
error type (lines ~7-15).
-
Extend the
errortype witherror_user_msg?: stringanderror_user_title?: string. -
Compute one human-friendly message near the top of the error block and use it in every
throw(401 / 429 slow-retry / terminal / rate-limit / 5xx / un-enumerated):const err = body.error; const message = err?.error_user_title && err?.error_user_msg ? `${err.error_user_title}: ${err.error_user_msg}` : err?.error_user_msg || err?.message || 'WhatsApp Graph API error';
-
graphError: body.errorstays incontextfor logs. No change todescribeError— it already returnserror.message, which now carries the readable text.
Effect: the existing create-template toast (describeError(error) in
template-builder.tsx) shows Meta's real message automatically. Send-failure paths that
persist errorMessage (DLQ, recipient logs) also get the readable message — a strict
improvement.
New file: libs/whatsapp-template-validation.ts. Pure and unit-tested (root libs/ is in
Vitest scope; app/ is not). Returns codes, not text — the component owns i18n.
type TemplateValidationCode =
// body — attaches to the 'bodyText' field (except body-missing-example → 'bodyExamples')
| 'body-adjacent-variables' // {{1}} {{2}} with no static text between them
| 'body-leading-variable' // body starts with a {{n}} (reported: 2388299)
| 'body-missing-example' // a body {{n}} has no filled-in sample
| 'body-non-sequential-variables' // must be {{1}},{{2}},… starting at 1, no gaps/dups
| 'body-trailing-variable' // body ends with a {{n}} (reported: 2388299)
// header TEXT — attaches to 'headerText' (except header-missing-example → 'headerExample')
| 'header-missing-example' // header has a variable but no filled-in sample
| 'header-multiple-variables'; // header TEXT allows at most one variable
type TemplateValidationIssue = {
code: TemplateValidationCode;
field: 'bodyExamples' | 'bodyText' | 'headerExample' | 'headerText';
};
const validate = (input: {
bodyExamples: Record<number, string>;
bodyText: string;
headerExample: string;
headerText: string;
headerType: 'NONE' | 'TEXT';
}): TemplateValidationIssue[] => {
/* … */
};
export type { TemplateValidationCode, TemplateValidationIssue };
export default { validate };Rule detail:
- body-leading-variable — trimmed body matches
/^\s*\{\{\s*\d+\s*\}\}/. - body-trailing-variable — trimmed body matches
/\{\{\s*\d+\s*\}\}\s*$/. - body-adjacent-variables — matches
/\{\{\s*\d+\s*\}\}\s*\{\{\s*\d+\s*\}\}/(two placeholders separated only by whitespace). - body-non-sequential-variables — the sorted distinct placeholders must equal
[1, 2, …, n](start at 1, no gaps, no duplicates). - body-missing-example — every body placeholder must have a non-empty trimmed value in
bodyExamples. - header rules run only when
headerType === 'TEXT':- header-multiple-variables — more than one distinct placeholder in the header text.
- header-missing-example — header text has a variable but
headerExampleis empty.
Header leading/trailing is not validated: Meta's 2388299 leading/trailing restriction is a body rule; the header's real constraint is "at most one variable" + example. (Implementation checkpoint: confirm against current Meta docs before coding, and only add a header leading/trailing rule if Meta actually rejects it — a false client-side block would prevent a valid template.)
The regex primitive lives here; app/components/whatsapp/template-placeholders.ts re-exports
extractPlaceholders from this lib so the {{n}} pattern has a single source of truth
(one-line change, no behavior change).
Single source of truth is the helper, consumed through a useMemo over the live
previewValue:
const validation = useMemo(() => {
const issues = whatsappTemplateValidation.validate({
bodyExamples: previewValue.bodyExamples,
bodyText: previewValue.bodyText,
headerExample: previewValue.headerExample,
headerText: previewValue.headerText,
headerType: previewValue.headerType
});
return { issues };
}, [previewValue]);- Code → message dictionary — a
useMemomapping eachTemplateValidationCodeto an__()sentence. Used by both the inline errors and the banner. - Inline (field level): convert the
bodyText/bodyExamples(andheaderText/headerExample)Form.Items to the render-function form and render the field's issue message in red below the field, matching the existingSmall+ red styling. The same helper is wired into eachForm.Item'srequiredfunction sorequiredErrorsCountcounts the issue and the existing submit guard (payload.requiredErrorsCount > 0) blocks submission. - Top banner: a restrained red box at the top of the form listing every issue message,
shown only when
validation.issuesis non-empty. Noui/alertcomponent exists, so build a small inline block matching the codebase'sborder-red-*/bg-red-50pattern (no new decorative styling — design restraint). - Save button:
disabled={validation.issues.length > 0}(in addition toloading). - Submit guard: in the
putTemplatelazyFetchRpcbody, bail when the helper reports issues for the submitted payload — the final gate even if the button state is bypassed (e.g. Enter key).
- i18n: one message per code (pt / es / en). Examples:
body-leading-variable→ "O texto não pode começar com uma variável {{1}}."body-trailing-variable→ "O texto não pode terminar com uma variável {{1}}."body-adjacent-variables→ "Inclua texto entre as variáveis (ex.: {{1}} e {{2}} não podem ficar coladas)."body-non-sequential-variables→ "Numere as variáveis em sequência começando em {{1}}."body-missing-example→ "Preencha um exemplo para cada variável."header-multiple-variables→ "O cabeçalho aceita no máximo uma variável."header-missing-example→ "Preencha um exemplo para a variável do cabeçalho."
- Tests:
libs/whatsapp-template-validation.spec.ts— oneit('should …')per code, plus valid cases and edges (empty body, no variables, whitespace-only, header NONE).- Backend — extend the existing whatsapp spec that exercises the error path to assert the
composed
error_user_title: error_user_msgmessage.
| File | Change |
|---|---|
worker/libs/whatsapp.ts |
Extend error type; compose human message in handleResponse. |
libs/whatsapp-template-validation.ts |
New pure validation lib (codes). |
libs/whatsapp-template-validation.spec.ts |
New unit tests. |
app/components/whatsapp/template-placeholders.ts |
Re-export extractPlaceholders from the new lib. |
app/components/whatsapp/template-builder.tsx |
useMemo validation, inline errors, top banner, disabled Save, submit guard, code→message dictionary. |
| i18n files (pt/es/en) | New message keys. |
| existing whatsapp backend spec | Assert composed human message. |
yarn lintclean,tscclean.- Full Vitest suite green (new lib spec + backend spec).
- Manual (app/ is untested): submit a body starting with
{{1}}→ blocked with inline + banner, no Meta call; force a real Meta rejection → toast shows the readableerror_user_title: error_user_msg.
- Header leading/trailing rule pending Meta-docs confirmation (see Piece 2).
- Any additional Meta rules (character limits, button-count limits) can be added later by extending the same helper.