Templates
Pre-approved WhatsApp copy. Required for every out-of-session message on production. Submitted to Meta, approved asynchronously, then usable by name.
Lifecycle
draft → submitted → approved (sendable)
↓
rejected (edit + resubmit)
↓
paused | disabledAuthentication templates use fixed Meta copy
Authentication is not a free-form BODY. Send exactly one BODY with optional add_security_recommendation and no text, plus exactly one OTP button. An optional FOOTER may carry code_expiration_minutes from 1 to 90; HEADER is unsupported. Meta renders the localized verification-code wording.
{
"name": "login_code",
"language": "en_US",
"category": "authentication",
"components": [
{ "type": "BODY", "add_security_recommendation": true },
{ "type": "BUTTONS", "buttons": [{ "type": "OTP", "otp_type": "COPY_CODE" }] }
]
}Create a draft
Template language is a string WhatsApp language code such as pt_BR or en_US. Do not send Meta's nested {"code":"pt_BR"} object shape.
curl https://api.tyxter.com/v1/templates \
-H "authorization: Bearer $TYXTER_API_KEY" \
-H "content-type: application/json" \
-d '{
"name": "order_update",
"language": "pt_BR",
"category": "utility",
"components": [
{
"type": "BODY",
"text": "Olá {{1}}, seu pedido {{2}} saiu para entrega.",
"example": { "body_text": [["Diego", "ORD-123"]] }
}
]
}'Component text is capped well above Meta's own limits — BODY at 4096 characters, a text HEADER or FOOTERat 1024 — so longer text is refused up front with 400 invalid_template_request (invalid_update_request on a PATCH) and error.param naming the component, for example components.0.text. The caps bound what the validators see; Meta's own limits (1024, 60, and 60) still apply at review.
Choose the parameter format
Public parameter_format values are uppercase POSITIONAL and NAMED. Omit it on create or generation for the compatiblePOSITIONAL default: positional tokens are {{1}}, {{2}}, … and use the existing body_text / header_text examples. NAMED BODY, text HEADER, and dynamic URL-button tokens must match {{[a-z][a-z0-9_]*}}. Each BODY or text-HEADER name needs exactly one non-empty { param_name, example } in the matching body_text_named_params or header_text_named_params array; URL buttons have no authoring example array.
Under default or explicit POSITIONAL, a BODY, text HEADER, or dynamic URL-button token such as {{full_name}} keeps the stable template_named_placeholder_unsupported error. Its message tells you to select NAMED and supply exact named examples where applicable, or to rewrite sequential positional tokens. Write each positional variable with nothing inside the braces but its number. ABODY or text HEADERtoken that is positional except for a space or an invisible character inside the braces — {{ 1 }}, or a no-break space before the digit — is refused as malformed with 400 invalid_template_request and error.param naming the component, rather than passed to Meta, which would either reject it after review or ship the braces as literal text. The message quotes the token and the {{n}} to write instead.
A NAMED authoring request is sent to Meta as canonical NAMED, and a submitted version snapshots that choice. Choose it before authoring the token/examples; changing format later is allowed only while the live template is draft or rejected and re-validates the merged components.
POSITIONAL and uses the unmodeled/provider-backstop path even if its text looks named; Tyxter does not infer a false NAMED authority from components. If a later provider read omits its format, reconciliation preserves an existing live or approved-version format. New authoring is always format-explicit or uses the positional default.On PATCH, omit parameter_format to retain the live format. On duplicate, omit it to inherit the source. Supplying a format override re-validates copied components, so send components and format together when changing authoring styles.
{
"parameter_format": "NAMED",
"components": [
{
"type": "HEADER",
"format": "TEXT",
"text": "Pedido {{order_id}}",
"example": {
"header_text_named_params": [{ "param_name": "order_id", "example": "ORD-123" }]
}
},
{
"type": "BODY",
"text": "Olá {{customer_name}}, pedido {{order_id}}.",
"example": {
"body_text_named_params": [
{ "param_name": "customer_name", "example": "Ana" },
{ "param_name": "order_id", "example": "ORD-123" }
]
}
},
{
"type": "BUTTONS",
"buttons": [{ "type": "URL", "text": "Acompanhar", "url": "https://example.com/orders/{{order_id}}" }]
}
]
}Where variables may sit
A marketing or utility BODY may not start or end on a format-valid variable. Meta rejects that shape, but only after review — so the draft looks healthy for hours and then lands in rejected. Tyxter refuses it up front instead: creating, patching, or duplicating-with-a-new-category such a body returns 400 template_variable_position_invalid, with error.param naming the offending component (components.0.text).
The fix is a word on the offending side: "{{1}}, seu pedido saiu" becomes "Olá {{1}}, seu pedido saiu". Only BODY components are checked, and only their outer edges — a variable in the middle of the body is fine, and a HEADER or FOOTER may begin or end with one. Trimming removes invisible characters as well as whitespace, at the two edges of the text only, so a zero-width space or bidi mark cannot mask an edge variable. Newly-authored authentication templates contain no free-form BODY text; Meta supplies the fixed OTP wording from their canonical components. The pure checker keeps its exemption only for legacy stored authentication JSON. This placement rule is judged after format compatibility, so a default POSITIONAL body that breaks both is answered with template_named_placeholder_unsupported.
On a PATCH the rule is applied to the merged template — your stored draft with the patch applied — so error.param can name a component the request did not send. The usual way to meet that is a patch that changes category or parameter_format: a legacy stored authentication body opening on {{1}} stops being legitimate once it becomes marketing or utility, and a format change rechecks the same stored components. Send compatible components with either authoring change.
category or parameter_format — still submits, and Meta can still reject it asynchronously. Re-save the body through PATCH /v1/templates/:id to get the synchronous answer.Button allowlist
A template button's accepted type is QUICK_REPLY, URL, PHONE_NUMBER, OTP, or COPY_CODE. Authentication templates require exactly one OTP button and the fixed BODY shape above; OTP buttons are rejected on marketing and utility templates.
Standalone type: "COPY_CODE" is a marketing coupon button. A template can have at most one; it has no customizable text and requires a non-empty example of at most 20 characters. This is distinct from authentication's type: "OTP" with otp_type: "COPY_CODE". Tyxter still rejects FLOW, MPM, SPM, CATALOG, and VOICE_CALL template buttons.
{
"name": "winter_coupon",
"language": "en_US",
"category": "marketing",
"components": [
{ "type": "BODY", "text": "Use this coupon at checkout." },
{ "type": "BUTTONS", "buttons": [{ "type": "COPY_CODE", "example": "WINTER25" }] }
]
}interactive.order_details with pix_dynamic_code.Generate a draft
Returns editable components from a short brief. Tyxter provides the generation provider; customers do not need to configure an LLM route before using this helper. In production each successful generation is a small billable operation (metered as template.generation); sandbox generations are free. Authentication generation returns the fixed Meta BODY + COPY_CODE OTP components rather than generated message text.
curl https://api.tyxter.com/v1/templates/generate \
-H "authorization: Bearer $TYXTER_API_KEY" \
-H "content-type: application/json" \
-d '{
"description": "Avisar que a conferencia online comeca em 15 minutos",
"language": "pt_BR",
"category": "utility",
"template_type": "text"
}'Submit for approval
Snapshots the current components and parameter_format into a fresh TemplateVersion, transitions the template to submitted, and enqueues the Meta sync worker. Returns 202 — actual Meta approval is async.
Campaign readiness
Before a campaign or broadcast, list templates and verify that the exact requested name/language has an approved version. A targeted send returns template_not_approved when the template is missing, still draft, rejected, paused, or disabled.
If no approved template exists, complete the create → submit → approval lifecycle first. Do not silently switch to another template or language.
Edit a draft
Draft and rejected templates can be edited. Image, document, and video headers must includeexample.header_handle[0], a Meta media handle used for review. Body and text header placeholders use format-matching samples: POSITIONAL uses example.body_text or example.header_text; NAMED uses exact body_text_named_params or header_text_named_params { param_name, example } arrays for every unique token.
Duplicate a template
Duplicating creates a new draft with copied components and no provider template id. Supply a new name when you want a predictable slug, or omit it to let Tyxter choose the next available_copy name.
Authoring and quality signals
Template responses include provider_quality andauthoring_signals. Provider quality is derived from the durable Meta lifecycle state. Authoring signals warn about likely category mismatches, such as promotional language inside a utility template, before you submit for review.
POST /v1/templates/generate returns the same authoring_signals array on its unsaved draft. Two signals to branch on there mirror the two authoring rules above, both with severity: "warning" and field: "components.body": template_named_placeholder appears only when a generated POSITIONAL draft uses a named token, while a valid NAMED draft uses named tokens and examples without that signal. template_variable_position means the BODY starts or ends on a format-valid variable. Saving a signaled positional draft as written would fail with template_named_placeholder_unsupported; saving an edge-variable draft would fail with template_variable_position_invalid. Each signal is computed by the same rule as its error, over the same components, so a warning and the eventual 400 can never disagree. Edit the body before calling POST /v1/templates. Note that signal names and error codes are different things: a signal is a value inside a successful response, a code is an error.code on a rejected request.
Approval webhooks
template.approvedfires when Meta accepts.template.rejectedfires when Meta declines —rejection_reasonis attached.template.paused/template.disabledfire when Meta pauses or disables a previously-approved template (e.g. sustained low quality). The template stops resolving as approved, so subsequent sends returntemplate_not_approveduntil it recovers.
Estimate cost before a broadcast
curl https://api.tyxter.com/v1/templates/tmpl_.../estimate-cost \
-H "authorization: Bearer $TYXTER_API_KEY" \
-H "content-type: application/json" \
-d '{ "recipients": 10000 }'Returns the meter id, per-recipient BRL amount, and total. Approved templates only — drafts and rejected templates 400 so you don't budget for sends Meta won't accept.
Inspect template analytics
Analytics combine TemplateVersion approval outcomes with message sends bound totemplate_id. Legacy rows that only have template_name are included as a compatibility fallback.
Use in a send
curl https://api.tyxter.com/v1/messages \
-H "authorization: Bearer $TYXTER_API_KEY" \
-H "content-type: application/json" \
-d '{
"channel": "whatsapp",
"sender": { "type": "whatsapp_phone_number", "id": "pn_..." },
"recipient": { "type": "phone_e164", "id": "+5511999999999" },
"message": {
"type": "template",
"template": {
"name": "order_update",
"language": "pt_BR",
"variables": { "1": "Diego", "2": "ORD-123" }
}
}
}'For an approved standalone COPY_CODE template, a direct send must include the button's zero-based index and exactly one coupon_code parameter (maximum 20 characters):
"components": [{
"type": "button",
"sub_type": "copy_code",
"index": 0,
"parameters": [{ "type": "coupon_code", "coupon_code": "WINTER25" }]
}]COPY_CODE templates are not supported by POST /v1/batches: the current batch contract has no per-recipient button-parameter source. Both inline-recipient and audience batches fail synchronously with template_param_mismatch.
SDK lifecycle
const template = await tyxter.templates.create({
name: "order_update",
language: "pt_BR",
category: "utility",
components: [
{
type: "BODY",
text: "Olá {{1}}, seu pedido foi recebido.",
example: { body_text: [["Diego"]] },
},
],
});
await tyxter.templates.update(template.id, {
components: [
{
type: "BODY",
text: "Olá {{1}}, seu pedido foi atualizado.",
example: { body_text: [["Diego"]] },
},
],
});
await tyxter.templates.submit(template.id);
await tyxter.templates.analytics(template.id);