Skip to main content

Templates

A template is a message pre-approved by Meta. It is the only way to write to someone outside the 24-hour window.

Templates belong to the WABA (business account), not to a number — every number on the same account shares the same library.

Categories​

CategoryFor whatCost
UTILITYConfirmation, order update, appointment reminderlow
AUTHENTICATIONVerification codelow
MARKETINGPromotion, announcement, re-engagementhigher

Meta reclassifies templates it considers miscategorised. A "utility" template that actually sells becomes MARKETING, and the cost goes up. allowCategoryChange: true (the default) lets that happen instead of rejecting the template.

Lifecycle​

Approval takes minutes to hours. The result arrives through the message_template_status_update webhook — don't poll in a loop.

PAUSED happens when many customers block or report messages from that template. It comes back on its own if quality recovers; if it gets worse, it becomes DISABLED and stops sending.

Variables and examples​

Every template with variables has to declare examples — Meta rejects it otherwise. Joinotify validates this before sending and returns 422 with an issues field pointing at the problem, which avoids the slow submit-wait-rejected cycle.

Positional format​

{
"name": "confirmacao_pedido",
"category": "UTILITY",
"language": "pt_BR",
"components": [
{
"type": "BODY",
"text": "Hi {{1}}, your order {{2}} is confirmed and arrives in {{3}}.",
"example": { "body_text": [["Maria", "1042", "2 business days"]] }
}
]
}

body_text is an array of arrays — Meta allows more than one example set. One is enough.

Named format​

{
"type": "BODY",
"text": "Hi {{name}}, your order {{number}} is confirmed.",
"example": {
"body_text_named_params": [
{ "param_name": "name", "example": "Maria" },
{ "param_name": "number", "example": "1042" }
]
}
}

More readable when a template has many variables. Pick one format and stay with it.

The validated rules​

RuleWhy
Every variable needs an exampleMeta uses the example to review the template
Examples can't be emptyAutomatic rejection
Positional numbering with no gaps{{1}}, {{3}} with no {{2}} is invalid
Don't mix positional and namedOne format per template
Text header: at most 1 variableMeta's limit
Footer: no variablesMeta's limit
A URL button with a variable needs a full example URLMeta validates the domain

When something fails:

422 Unprocessable Entity
{
"error": {
"message": "Invalid template components.",
"type": "invalid_request",
"issues": [{ "path": "components[0].text", "message": "Variable {{2}} has no example value." }]
}
}

Creating​

curl -X POST https://api.joinotify.com/templates \
-H 'Authorization: Bearer sk_live_xxx' \
-H 'Content-Type: application/json' \
-d '{
"name": "confirmacao_pedido",
"category": "UTILITY",
"language": "pt_BR",
"components": [
{ "type": "HEADER", "format": "TEXT", "text": "Order confirmed" },
{
"type": "BODY",
"text": "Hi {{1}}, your order {{2}} is confirmed.",
"example": { "body_text": [["Maria", "1042"]] }
},
{ "type": "FOOTER", "text": "Joinotify" },
{
"type": "BUTTONS",
"buttons": [
{ "type": "URL", "text": "Track it", "url": "https://example.com/orders/{{1}}", "example": ["https://example.com/orders/1042"] }
]
}
]
}'

name accepts only lowercase letters, digits and underscore. It is the sending key — pick it thinking about how you'll call it in code, not how it looks to the customer.

Listing​

curl 'https://api.joinotify.com/templates?status=APPROVED' \
-H 'Authorization: Bearer sk_live_xxx'

The listing comes from a local mirror, updated by webhook and synced on demand when it gets older than 15 minutes.

FieldWhat for
staletrue when the mirror is old and the sync failed
syncedAtWhen the mirror was last updated
syncSummary of the sync, when one ran on this request
syncErrorWhy it failed — cached data is still returned

refresh=true forces a sync; refresh=false serves cache only. For a listing screen, refresh=false is the right call: it's instant and the mirror keeps itself current.

Syncing manually​

curl -X POST https://api.joinotify.com/templates/sync \
-H 'Authorization: Bearer sk_live_xxx' \
-H 'Content-Type: application/json' \
-d '{ "all": true }'

Only needed in two cases: templates created directly in Business Manager, or a suspicion that a webhook was lost. A sync already running returns 202 and nothing is duplicated.

Removing​

curl -X DELETE 'https://api.joinotify.com/templates/confirmacao_pedido' \
-H 'Authorization: Bearer sk_live_xxx'

Removal covers every language under that name. Messages already sent stay in the customers' conversations.

The name is locked for 30 days

After deleting, Meta won't let you create another template with the same name for 30 days. If the goal is to fix the content, edit rather than delete and recreate.

Sending​

curl -X POST https://api.joinotify.com/messages \
-H 'Authorization: Bearer sk_live_xxx' \
-H 'Content-Type: application/json' \
-d '{
"type": "template",
"to": "5541987111527",
"name": "confirmacao_pedido",
"language": "pt_BR",
"components": [
{ "type": "header", "parameters": [{ "type": "text", "text": "Order confirmed" }] },
{ "type": "body", "parameters": [
{ "type": "text", "text": "Maria" },
{ "type": "text", "text": "1042" }
]},
{ "type": "button", "sub_type": "url", "index": "0",
"parameters": [{ "type": "text", "text": "1042" }] }
]
}'

Note the asymmetry: at creation the components are uppercase (BODY), at send lowercase (body). That's how Meta does it and we pass it through unchanged.

On a URL button, the parameter is only the variable part — in the example above, 1042 completes https://example.com/orders/{{1}}.

Common send errors​

Meta codeWhat happened
132000Parameter count doesn't match the template
132001Template doesn't exist in that language
132005Parameter too long
132007Template has an invalid format
132012Wrong parameter format
132015Template paused for quality
132016Template disabled for quality

All of them arrive inside error.meta on a 422 meta_error response.