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
| Category | For what | Cost |
|---|---|---|
UTILITY | Confirmation, order update, appointment reminder | low |
AUTHENTICATION | Verification code | low |
MARKETING | Promotion, announcement, re-engagement | higher |
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
| Rule | Why |
|---|---|
| Every variable needs an example | Meta uses the example to review the template |
| Examples can't be empty | Automatic rejection |
| Positional numbering with no gaps | {{1}}, {{3}} with no {{2}} is invalid |
| Don't mix positional and named | One format per template |
| Text header: at most 1 variable | Meta's limit |
| Footer: no variables | Meta's limit |
| A URL button with a variable needs a full example URL | Meta validates the domain |
When something fails:
{
"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.
| Field | What for |
|---|---|
stale | true when the mirror is old and the sync failed |
syncedAt | When the mirror was last updated |
sync | Summary of the sync, when one ran on this request |
syncError | Why 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.
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 code | What happened |
|---|---|
132000 | Parameter count doesn't match the template |
132001 | Template doesn't exist in that language |
132005 | Parameter too long |
132007 | Template has an invalid format |
132012 | Wrong parameter format |
132015 | Template paused for quality |
132016 | Template disabled for quality |
All of them arrive inside error.meta on a 422 meta_error response.