Template Definition
A template definition is the JSON document that describes a template's content. It is the required definition field of the Create Template (POST /v3/templates) and Update Template (PUT /v3/templates/{id}) requests. The dashboard template builder displays the same JSON via its View JSON action.
Top-level request fields use snake_case (submit_for_review); field names inside definition use camelCase (multiChannel, variableType).
Create template request
| Field | Type | Required | Description |
|---|---|---|---|
category | string | No | MARKETING, UTILITY, or AUTHENTICATION. Auto-detected from the template content when omitted |
language | string | No | Language code (for example, en_US). Auto-detected from the template content when omitted |
definition | object | Yes | The definition object |
creation_source | string | No | Source label for the template. Default: from-api |
submit_for_review | boolean | No | true submits the template for review immediately after creation. Default: false, which saves the template with status DRAFT |
sandbox | boolean | No | true validates the request and returns a simulated template without creating anything. Default: false. See Sandbox mode |
There is no name field on create: the display name is derived from the template's content. To rename a template afterwards, send name on Update Template (PUT /v3/templates/{id}).
Request headers
Beyond the x-api-key header that authenticates every call, the template endpoints accept two optional headers:
| Header | Accepted on | Purpose |
|---|---|---|
Idempotency-Key | POST /v3/templates, PUT /v3/templates/{id} | Makes a retry safe: the first response is cached for 24 hours per key per customer and replayed instead of creating a second template. 1–255 characters matching ^[a-zA-Z0-9_-]+$. See Idempotency |
x-profile-id | Every template endpoint | Scopes the request to a child profile, so the template is created, listed, or changed under that profile rather than the key's own account. Organization API keys only, and the profile must belong to the calling organization. See Multi-tenant architectures |
GET and DELETE ignore Idempotency-Key, which is why it is not offered on them. A profile-scoped request echoes the profile back in the X-Profile-Id response header, and the template's owner is reported as customer_id on the template itself.
Definition object
| Field | Type | Required | Description |
|---|---|---|---|
header | object | No | Header object |
body | object | Yes | Body object |
footer | object | No | Footer object |
buttons | array | No | Array of button objects |
definitionVersion | string | No | Version of the template definition format |
authenticationConfig | object | No | Authentication configuration, used only by AUTHENTICATION templates |
Channel support
| Component | SMS | RCS | MMS | |
|---|---|---|---|---|
| Header | Not supported | Native header | Prepended to message text | Not supported; MMS has its own subject instead |
| Body | Supported | Supported | Supported | Supported, from the mms body only |
| Footer | Not supported | Native footer | Appended to message text | Not supported |
| Buttons | Not supported | Interactive buttons | First four shown as suggestion chips | Not supported |
MMS is the one channel that renders no header, footer, or buttons at all: it is a carrier message carrying a subject, text, and attachments, with nowhere to put them.
Header object
| Field | Type | Required | Description |
|---|---|---|---|
type | string | No | Header type: text, image, video, or document |
template | string | Yes | Header text with optional variable placeholders. Maximum 60 characters |
variables | array | No | Variable objects used in the header. Maximum 1 |
Text headers must not contain newlines, emojis, or formatting markup (*, _, ~). AUTHENTICATION templates do not use headers; Sent excludes any header from them automatically.
Body object
The body carries the message copy. Pick one of two authoring strategies:
- a shared
multiChannelbody on its own, or - an explicit
sms+whatsapppair, with both present.
rcs and mms are optional on top of either strategy.
| Field | Type | Required | Description |
|---|---|---|---|
multiChannel | object | Conditional | The body used for every channel. Required unless you send both sms and whatsapp |
sms | object | Conditional | The SMS body. Must be paired with whatsapp, and cannot be combined with multiChannel |
whatsapp | object | Conditional | The WhatsApp body. Must be paired with sms, and cannot be combined with multiChannel |
rcs | object | No | RCS-specific copy that overrides the chosen strategy for RCS only. Cannot be the only body present |
mms | object | No | MMS body content object: the subject, copy, and attachments MMS sends. Cannot be the only body present, and its absence makes the template not MMS-capable |
The channel bodies are a strategy, not overrides
sms and whatsapp do not override multiChannel, they replace it. The API refuses a body that mixes the two strategies (multiChannel alongside sms or whatsapp), and it equally refuses sms on its own, whatsapp on its own, or rcs on its own: every template is expected to be deliverable on every channel. A body that breaks the rule fails with "Body must have either multiChannel OR both sms and whatsapp channels (not both strategies). An rcs or mms body may accompany either, but cannot stand on its own."
Body content object
| Field | Type | Required | Description |
|---|---|---|---|
type | string | No | The body block's type: send text |
template | string | Yes | Body text with variable placeholders. Maximum 1024 characters, or 3072 in an rcs body and 1600 in an mms body, and subject to the body content rules |
variables | array | No | Variable objects used in the body, one entry per {{index:variable}} placeholder. IDs must be unique within the body |
Always send the body type
type is schema-optional, but a body posted without it is stored with no type key at all, and the dashboard template builder has nothing to render the block from. Send "type": "text" on every body content object.
MMS body content object
The mms body is the body content object plus the two members only MMS has: a subject line and attachments.
| Field | Type | Required | Description |
|---|---|---|---|
type | string | No | The body block's type: send text |
template | string | Conditional | Body text with variable placeholders. Maximum 1600 characters, a wider cap than the other channels because an MMS text part is not bound by Meta's limit. Optional when media carries at least one item |
variables | array | No | Variable objects used in template and in subject |
subject | string | No | Subject line, maximum 80 characters, rendered above the body on most handsets and ignored by some. Variables are substituted, and the result is truncated rather than rejected if it overruns |
media | array | Conditional | One MMS media item. MMS carries one attachment, so a second is rejected at authoring time. Optional when template carries text |
An mms body must carry body text, at least one media item, or both. One that carries neither renders to nothing and is rejected at authoring time with "MMS body must carry body text, at least one media item, or both." A template saved with more than one media item before the one-attachment limit still sends, carrying only its first.
MMS never falls back to another body
Every other channel resolves to a shared body when it has no dedicated one: rcs falls back to multiChannel and then to sms. MMS resolves to the mms body and nothing else. A template without one is not MMS-capable, produces no MMS route candidate, and cannot be sent with "channel": ["mms"]. This is deliberate: MMS with no media is a more expensive SMS.
MMS media item
| Field | Type | Required | Description |
|---|---|---|---|
url | string | Yes | Publicly fetchable https URL on a hostname. A literal IP address is refused. The carrier fetches it at send time, including on retries, so it must stay reachable and unauthenticated for the life of the send. A presigned URL is not a valid value. Sent reads the size it declares before each send, and an attachment over 600 KB is not sent as MMS (see Media Size Limits) |
mediaType | string | No | One of image, video, document, or contact. Any other value, including audio, is rejected. Advisory only: the carrier reads the Content-Type of the fetched object, not this field. It exists so an authoring UI can preview the attachment and a reviewer can see what was intended |
Media URLs are never templated. A variable is substituted in template and subject but copied verbatim in url, so an attachment cannot be assembled from caller input.
A per-send media_urls on POST /v3/messages replaces this list rather than adding to it, which lets a template hold a default creative while a caller sends something recipient-specific. See Sending MMS for the sending side.
Footer object
| Field | Type | Required | Description |
|---|---|---|---|
type | string | No | Footer type: text |
template | string | Yes | Footer text. Maximum 60 characters |
variables | array | No | Must be empty; footers cannot contain variables |
Footers must not contain variables, newlines, emojis, or formatting markup (*, _, ~).
Buttons array
A template holds a maximum of 10 buttons. Button IDs must be unique. Per-type limits: maximum 1 COPY_CODE, 1 PHONE_NUMBER, 2 URL, and 10 QUICK_REPLY buttons.
| Field | Type | Required | Description |
|---|---|---|---|
id | number | No* | Button identifier (1-based index), unique within the template. *Required in practice once a template has two or more buttons, see below |
type | string | Yes | QUICK_REPLY, URL, VOICE_CALL, PHONE_NUMBER, or COPY_CODE |
props | object | Yes | Type-specific button properties |
Button props object
| Field | Type | Applies to | Description |
|---|---|---|---|
text | string | All types | Button label. Maximum 25 characters, static text only (see below) |
quickReplyType | string | QUICK_REPLY | custom or pre-configured |
urlType | string | URL | static or dynamic |
url | string | URL | Destination URL. Maximum 2000 characters |
variables | array | URL (dynamic) | Exactly 1 variable object; its placeholder must appear at the end of url |
activeFor | number | VOICE_CALL | Integer greater than 0 |
countryCode | string | PHONE_NUMBER | Country code (for example, US) |
phoneNumber | string | PHONE_NUMBER | Phone number in international format |
offerCode | string | COPY_CODE | Code copied to the clipboard on tap |
otpType | string | AUTHENTICATION OTP buttons | COPY_CODE or ONE_TAP |
Button labels take static text only
Meta allows no dynamic content in a button label, so text is refused when it contains a {{...}} variable placeholder, a newline, an emoji, or WhatsApp formatting markup (*, _, ~). Meta reports all four as a single error ("Buttons can't have any variables, newlines, emojis, or formatting characters."), so Sent checks them at save time instead, one message per rule. Put the dynamic part in the body, or in a dynamic URL button's url, where placeholders are allowed.
Number your buttons when there are two or more
id is schema-optional, but it is a plain integer rather than a nullable one: a button that omits it is stored with id: 0. One button that way is fine, two are not: they collide, and the template is refused with "Button IDs must be unique." Number them from 1 in the order you want them shown, which also decides which four reach RCS.
Variable object
Variables use a numbered placeholder syntax in template text: {{0:variable}} for text variables and {{4:link}} for links, where the number is the variable's id and the word after the colon is its type. This is the canonical form, and the one the dashboard builder writes.
Two shorter forms are also accepted in header and body text, and resolve to the same variable:
| Form | Example | Notes |
|---|---|---|
{{id:type}} | {{0:variable}} | Canonical. Use this form |
{{name}} | {{customerName}} | Matched against the variable's name |
{{id}} | {{0}} | Matched against the variable's id |
Dynamic URL buttons take the numeric form only
A dynamic URL button is validated with a numeric-only pattern, so its url must end in {{0:variable}} or {{0}}. A named placeholder such as https://example.com/orders/{{orderNumber}} fails with "Dynamic URL must end with a variable placeholder (e.g. {{0:variable}})."
| Field | Type | Required | Description |
|---|---|---|---|
id | number | No* | Sequential ID starting from 0, unique within its section. *Required in practice once a section has two or more variables, and it is the number the {{0:variable}} placeholder refers to |
name | string | Yes | Readable identifier for the variable (for example, orderNumber) |
type | string | Yes | variable, link, or media |
props | object | Yes | Variable properties |
Placeholder types are validated at save time
The type token in a {{index:type}} placeholder must be variable, link, or media. Any other value is rejected when the template is saved:
"template contains a placeholder with an unsupported type. Placeholders must use 'variable', 'link', or 'media' (e.g. {{0:variable}})."
Before this validation was enforced, an unrecognized type such as {{0:message}} was stored without error. At send time the placeholder could not be resolved, so the literal string was delivered to the recipient instead of the substituted value.
Variable props object
| Field | Type | Applies to | Description |
|---|---|---|---|
variableType | string | variable | Required. text, link, image, or file |
sample | string | variable | Required. Example value shown in previews and used during review |
regex | string | variable | Optional validation pattern for the variable value |
url | string | link, media | Required. Full HTTP or HTTPS URL |
shortUrl | string | link | Optional shortened URL |
alt | string | link, media | Alternative text |
mediaType | string | media | Required. image, video, or document |
Authentication configuration
AUTHENTICATION templates accept an authenticationConfig object:
| Field | Type | Description |
|---|---|---|
addSecurityRecommendation | boolean | Adds the text "For your security, do not share this code." |
codeExpirationMinutes | number | 1–90. When set, adds the footer "This code expires in X minutes." |
An AUTHENTICATION body must declare exactly one text variable (the code) and must not contain links or emojis.
Content rules and limits
| Rule | Limit |
|---|---|
| Body length | 1024 characters for the multiChannel, sms and whatsapp bodies; 3072 for an rcs body; 1600 for an mms body |
| MMS subject length | 80 characters |
| MMS media items | 10 per mms body |
| Header and footer length | 60 characters each |
| Header variables | 1 |
| Buttons per template | 10 |
| Button label length | 25 characters |
| Button URL length | 2000 characters |
| Variables in a dynamic URL button | Exactly 1, at the end of the URL |
Emojis in a MARKETING body | 10 (dashboard only, see below) |
| Placeholder types | variable, link, or media. Any other type is refused at save time |
The emoji cap is checked on dashboard creation only
The 10-emoji cap on MARKETING bodies is applied when a template is first created in the dashboard. The v3 API does not check it, so POST /v3/templates and PUT /v3/templates/{id} both accept a MARKETING body carrying more, and no update path re-checks it. Treat it as the limit to design to rather than a validation you can rely on.
Length limits are validated at save time against the stored text, with {{...}} placeholders unsubstituted. Counting is encoding-blind: each UTF-16 code unit counts as one character (the behavior of .NET's string.Length and JavaScript's String.prototype.length), so characters in the Basic Multilingual Plane count as 1 and most emoji count as 2. There is no post-substitution validation; a body within the limit can render into a longer message once variables expand. For how rendered length maps to SMS segments, see SMS Encoding & Message Length.
Meta requires every variable to carry surrounding context, so body text must additionally satisfy all of these rules. A body that fails any of them is refused at save time:
- At least one letter before the first variable and after the last. Trailing punctuation does not count:
... {{1:variable}}.fails the rule. - At least (2 × variable count) + 1 words once the placeholders are removed.
- No two variables adjacent with only whitespace between them.
- The text cannot begin or end with a newline, run more than two line breaks together, or run more than four spaces together.
For example, Hello {{0:variable}}! Welcome to {{1:variable}}. We are glad to have you on board. passes: it has two variables, so at least five words are required, and the copy after the final variable contains letters.
AUTHENTICATION bodies are exempt from these rules and are held to Meta's authentication format instead.
Template statuses
| Status | Meaning |
|---|---|
DRAFT | Created but not submitted. Editable and testable; cannot be sent on any channel |
PENDING | Submitted for review; awaiting a decision |
APPROVED | Approved; available for sending |
REJECTED | Declined by the reviewer. A rejected template must be revised and resubmitted before it can send |
PAUSED | Paused by Meta for repeated low quality. The template stays published, but sends against it are blocked until it is reinstated |
DISABLED | Disabled by Meta, typically for a policy violation. Treated as a takedown: nothing sends |
REVOKED | Taken down on a channel by Sent or Meta. Nothing sends on that channel until it is reinstated |
Review routing depends on your account:
- With a connected WhatsApp Business Account, Meta reviews the template. A Meta
APPROVEDorPENDINGverdict applies to every channel (SMS, WhatsApp, and RCS); aREJECTEDverdict applies to the WhatsApp channel only. - Without a connected WhatsApp Business Account, Sent's compliance team reviews the template, and each channel is approved or rejected individually.
Approval is tracked per channel. A message sends on a channel only when the template is approved for that channel; sends against unapproved templates return status BLOCKED. Status changes are delivered as templates webhook events carrying the template ID, status, reason, and the channel leg the decision applies to. Each channel is reviewed independently and reports separately, so expect one event per leg. channel is omitted when the decision applies to the template as a whole rather than to one leg; that event is the broader news, because a template-wide rejection blocks every channel whatever the individual legs say.
What a status allows you to change
Update Template (PUT /v3/templates/{id}) treats name differently from the rest of the template. The display name is editable in every status. The content fields and resubmission are gated:
| Status | name | definition, category, language | submit_for_review |
|---|---|---|---|
DRAFT | Editable | Editable | Submits for review |
REJECTED | Editable | Editable | Resubmits for review |
APPROVED | Editable | Editable as a live edit | Re-opens review |
PENDING | Editable | Frozen: 409 CONFLICT_006 | Frozen: 409 CONFLICT_006 |
PAUSED, DISABLED, REVOKED | Editable | Refused: 400 VALIDATION_001 | Accepted, but does nothing |
A template is also treated as in review, and so frozen, when its own status is not PENDING but any of its channels is still awaiting a verdict. The 409 carries the message "This template is in review; its definition, category and language are frozen until the review completes".
From PAUSED, DISABLED, or REVOKED, an edit to definition, category, or language is refused with a 400 whose details.request reads "Template (except display name) cannot be updated unless it is in draft or rejected status". To change the content of a paused or taken-down template, create a new one.
Resubmitting a paused or taken-down template is a no-op
submit_for_review: true on a template in PAUSED, DISABLED, or REVOKED is accepted and answers 200, but no review is opened and the status does not move. Only the reviewer can reinstate the template, so treat the 200 as a stored request rather than a resubmission, and confirm through the templates webhook or a follow-up GET.
Editing an approved template
An approved template can be edited in place; this is a live edit. The new content is stored immediately, and sending submit_for_review: true re-opens review, which returns the affected channels to PENDING. Those channels stop sending until they are approved again, and the previously approved content is not sent in the meantime. Plan for a gap, or keep a second approved template to send from while the edit is in review.
How much of the template goes back to PENDING depends on how your account is reviewed, so watch the per-channel templates webhook events rather than reading the template-level status alone.
Sent-provisioned templates are read-only
Templates that Sent provisions into your account (the pre-built OTP and verification templates that come with light onboarding) are read-only. They accept only submit_for_review; any other field is refused with 400 VALIDATION_001 and the detail "This template is read-only. Only 'submit for review' is allowed." To send different content, create your own template.
Relatedly, the sent_ prefix is reserved for these templates, so a name beginning with sent_ is refused on a template of your own. Pick any other name.
Example
A complete POST /v3/templates request body with a header, a multi-channel body using text variables and a dynamic link, a footer, and two buttons:
{
"category": "UTILITY",
"language": "en_US",
"submit_for_review": false,
"definition": {
"header": {
"type": "text",
"template": "Order {{0:variable}}: Delivery Update",
"variables": [
{
"id": 0,
"name": "orderNumber",
"type": "variable",
"props": {
"variableType": "text",
"sample": "12345"
}
}
]
},
"body": {
"multiChannel": {
"type": "text",
"template": "Hi {{0:variable}},\nYour Acme order {{1:variable}} has been shipped and is expected to arrive {{2:variable}} between {{3:variable}}.\nYou can track your package or update your delivery preferences at {{4:link}} before the driver arrives.",
"variables": [
{
"id": 0,
"name": "customerName",
"type": "variable",
"props": {
"variableType": "text",
"sample": "Lucas"
}
},
{
"id": 1,
"name": "orderNumber",
"type": "variable",
"props": {
"variableType": "text",
"sample": "#12345"
}
},
{
"id": 2,
"name": "arrivalDate",
"type": "variable",
"props": {
"variableType": "text",
"sample": "tomorrow (Oct 11)"
}
},
{
"id": 3,
"name": "arrivalTime",
"type": "variable",
"props": {
"variableType": "text",
"sample": "2 PM – 4 PM"
}
},
{
"id": 4,
"name": "orderLink",
"type": "link",
"props": {
"url": "https://example.com",
"shortUrl": "",
"alt": "Tracking Page"
}
}
]
}
},
"footer": {
"type": "text",
"template": "Thank you for shopping with Acme.",
"variables": []
},
"buttons": [
{
"id": 1,
"type": "URL",
"props": {
"text": "Track your order",
"urlType": "static",
"url": "https://www.example.com/track"
}
},
{
"id": 2,
"type": "PHONE_NUMBER",
"props": {
"text": "Acme customer support",
"countryCode": "US",
"phoneNumber": "+112345678"
}
}
]
}
}The endpoint responds with the standard response envelope; the created template's fields are documented on the Create Template endpoint page.
Get message summary for a contact GET
Returns aggregate message counts, time bounds, channels used, and per-channel success/fail scores (each as a percentage 0-100 of messages on that channel) for one of your contacts. Successful terminal states: SENT/DELIVERED/READ for outbound, RECEIVED for inbound. Fail: FAILED.
Get templates list GET
Retrieves a paginated list of message templates for the authenticated customer. Supports filtering by status, category, and search term.