Skip to content

Instantly share code, notes, and snippets.

@feliperohdee
Created July 17, 2026 21:35
Show Gist options
  • Select an option

  • Save feliperohdee/2581b823855452421e38848f3bd9a00f to your computer and use it in GitHub Desktop.

Select an option

Save feliperohdee/2581b823855452421e38848f3bd9a00f to your computer and use it in GitHub Desktop.
spec sample

WhatsApp Template Validation + Meta Error Surfacing

Date: 2026-07-17 Branch: poc-whats Status: Design approved, pending spec review

Problem

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:

  1. The useful message is hidden. Meta returns a generic message: "Invalid parameter" plus the human-readable error_user_title ("Parâmetros iniciais ou finais não permitidos") and error_user_msg ("As variáveis não podem estar no início ou no fim do modelo."). Today the backend throws with the generic message, so the user only sees "Invalid parameter".
  2. 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.

Goals

  • 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.

Non-Goals

  • 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.

Decisions (confirmed)

  • 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_msg server-side (so the toast, DLQ logs, and recipient logs all get the readable message).

Architecture

Four independent pieces.

Piece 1 — Backend: surface Meta's human message

File: worker/libs/whatsapp.ts (handleResponse, lines ~357-440) and the GraphResponse error type (lines ~7-15).

  • Extend the error type with error_user_msg?: string and error_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.error stays in context for logs. No change to describeError — it already returns error.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.

Piece 2 — Shared validation lib (root libs/)

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 headerExample is 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).

Piece 3 — Frontend wiring (app/components/whatsapp/template-builder.tsx)

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 useMemo mapping each TemplateValidationCode to an __() sentence. Used by both the inline errors and the banner.
  • Inline (field level): convert the bodyText / bodyExamples (and headerText / headerExample) Form.Items to the render-function form and render the field's issue message in red below the field, matching the existing Small + red styling. The same helper is wired into each Form.Item's required function so requiredErrorsCount counts 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.issues is non-empty. No ui/alert component exists, so build a small inline block matching the codebase's border-red-* / bg-red-50 pattern (no new decorative styling — design restraint).
  • Save button: disabled={validation.issues.length > 0} (in addition to loading).
  • Submit guard: in the putTemplate lazyFetchRpc body, bail when the helper reports issues for the submitted payload — the final gate even if the button state is bypassed (e.g. Enter key).

Piece 4 — i18n + tests

  • 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 — one it('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_msg message.

Files touched

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.

Verification

  • yarn lint clean, tsc clean.
  • 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 readable error_user_title: error_user_msg.

Out of scope / follow-ups

  • 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment