Create Template
Create a WhatsApp template and submit it to Meta for review in one call. WhatsApp only lets a business start a conversation with an approved template.
Approval is asynchronous. The template is returned with status: "pending". Poll Get Template until it is approved or rejected; review usually takes a few minutes and can take up to 48 hours.
Endpoint
POST /whatsapp/templates
Returns: 201 Created
Your organization needs a connected WhatsApp Business account, set up in the dashboard under Integration > WhatsApp.
Headers
| Header | Value | Required |
|---|---|---|
Authorization | Bearer <your-api-key> | Yes |
Content-Type | application/json | Yes |
Body Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Lowercase letters, digits and underscores, starting with a letter. Max 512 characters. Unique per language. |
language | string | Yes | Meta language code, e.g. en, ar or en_US. |
category | string | Yes | marketing, utility or authentication. |
components | object[] | For marketing and utility | The template content. See Components. Not accepted for authentication. |
authentication | object | For authentication | The options of a one-time-code template. See Authentication templates. |
utility and authentication templates are sent with POST /whatsapp/send. A marketing template can be created here, but it is sent as a campaign from the dashboard, which checks marketing consent.
Components
components uses Meta's own template format and is passed through unchanged, so type, format and button type are written in uppercase exactly as Meta documents them.
| Component | Fields | Notes |
|---|---|---|
HEADER | format, text | Optional. format must be TEXT, and text is at most 60 characters. Image, video and document headers are not available through the API yet; create those in the dashboard. |
BODY | text | Required, exactly one. At most 1024 characters. |
FOOTER | text | Optional. |
BUTTONS | buttons[] | Optional. Each button has a type of QUICK_REPLY, URL or PHONE_NUMBER, a text label, and a url or phone_number where it applies. |
Placeholders
Write placeholders as named variables in lowercase snake_case: {{first_name}}, {{order_id}}. A name starts with a letter, uses only lowercase letters, digits and underscores, and is at most 60 characters. Numbered placeholders such as {{1}} are not accepted, and a name can be used in the header or the body, not both.
The names you choose are the keys you pass in parameters when you send the template. The response lists them in parameters.
Meta requires a sample value for every placeholder when it reviews a template. Arsel fills these in for you. To supply your own, add Meta's example object to the component:
{
"type": "BODY",
"text": "Hi {{first_name}}, order {{order_id}} has shipped.",
"example": {
"body_text_named_params": [
{ "param_name": "first_name", "example": "Sara" },
{ "param_name": "order_id", "example": "A-1029" }
]
}
}
Authentication templates
Meta fixes the wording of an authentication template, so you choose options instead of writing components. The one-time code is sent as parameters.code.
{
"name": "login_code",
"language": "en",
"category": "authentication",
"authentication": {
"otp_type": "copy_code",
"code_expiration_minutes": 10
}
}
| Field | Type | Required | Description |
|---|---|---|---|
otp_type | string | Yes | copy_code, one_tap or zero_tap: how the recipient gets the code off the screen. |
add_security_recommendation | boolean | No | Appends "For your security, do not share this code." to the body. |
code_expiration_minutes | number | No | Adds a footer reading "This code expires in N minutes." 1 to 90. |
button_text | string | No | Button label, max 25 characters. Meta supplies a localized default. |
autofill_text | string | No | Label shown while autofilling. One-tap and zero-tap only. Max 25 characters. |
supported_apps | object[] | For one_tap and zero_tap | Up to 5 Android apps allowed to autofill the code, each { "package_name", "signature_hash" }. |
zero_tap_terms_accepted | boolean | For zero_tap | Must be true: accepts Meta's zero-tap terms. |
message_send_ttl_seconds | number | No | How long Meta may keep retrying delivery before dropping the code. 30 to 900. |
Response
{
"id": "01957e3a-4b5c-7d8e-9f0a-1b2c3d4e5f6a",
"name": "order_shipped",
"language": "en",
"category": "utility",
"status": "pending",
"rejection_reason": null,
"parameters": [
"first_name",
"order_id"
],
"components": [
{
"type": "HEADER",
"format": "TEXT",
"text": "Your order"
},
{
"type": "BODY",
"text": "Hi {{first_name}}, order {{order_id}} has shipped."
},
{
"type": "FOOTER",
"text": "Arsel Store"
},
{
"type": "BUTTONS",
"buttons": [
{
"type": "URL",
"text": "Track",
"url": "https://example.com/track"
}
]
}
],
"created_at": "2026-03-08T12:00:00.000Z",
"updated_at": "2026-03-08T12:00:00.000Z"
}
See Get Template for the field reference.
Retries
A template is identified by its name and language. If you repeat a create that already succeeded, you get a 409; read the template back with GET /whatsapp/templates?name=order_shipped.
Examples
- cURL
- JavaScript
- Python
- C#
- PHP
curl -X POST "https://api.arsel.sa/v1/whatsapp/templates" \
-H "Authorization: Bearer $ARSEL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "order_shipped",
"language": "en",
"category": "utility",
"components": [
{
"type": "HEADER",
"format": "TEXT",
"text": "Your order"
},
{
"type": "BODY",
"text": "Hi {{first_name}}, order {{order_id}} has shipped."
},
{
"type": "FOOTER",
"text": "Arsel Store"
},
{
"type": "BUTTONS",
"buttons": [
{
"type": "URL",
"text": "Track",
"url": "https://example.com/track"
}
]
}
]
}'
const response = await fetch("https://api.arsel.sa/v1/whatsapp/templates", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ARSEL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "order_shipped",
language: "en",
category: "utility",
components: [
{
type: "HEADER",
format: "TEXT",
text: "Your order",
},
{
type: "BODY",
text: "Hi {{first_name}}, order {{order_id}} has shipped.",
},
{
type: "FOOTER",
text: "Arsel Store",
},
{
type: "BUTTONS",
buttons: [
{
type: "URL",
text: "Track",
url: "https://example.com/track",
},
],
},
],
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(`${data.name}: ${data.message}`);
}
console.log(data);
import os
import requests
response = requests.post(
"https://api.arsel.sa/v1/whatsapp/templates",
headers={"Authorization": f"Bearer {os.environ['ARSEL_API_KEY']}"},
json={
"name": "order_shipped",
"language": "en",
"category": "utility",
"components": [
{
"type": "HEADER",
"format": "TEXT",
"text": "Your order",
},
{
"type": "BODY",
"text": "Hi {{first_name}}, order {{order_id}} has shipped.",
},
{
"type": "FOOTER",
"text": "Arsel Store",
},
{
"type": "BUTTONS",
"buttons": [
{
"type": "URL",
"text": "Track",
"url": "https://example.com/track",
},
],
},
],
},
)
response.raise_for_status()
print(response.json())
using System.Net.Http.Headers;
using System.Text;
var client = new HttpClient(); // share one instance across requests
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("ARSEL_API_KEY"));
var request = new HttpRequestMessage(HttpMethod.Post, "https://api.arsel.sa/v1/whatsapp/templates")
{
Content = new StringContent(
"""
{
"name": "order_shipped",
"language": "en",
"category": "utility",
"components": [
{
"type": "HEADER",
"format": "TEXT",
"text": "Your order"
},
{
"type": "BODY",
"text": "Hi {{first_name}}, order {{order_id}} has shipped."
},
{
"type": "FOOTER",
"text": "Arsel Store"
},
{
"type": "BUTTONS",
"buttons": [
{
"type": "URL",
"text": "Track",
"url": "https://example.com/track"
}
]
}
]
}
""",
Encoding.UTF8,
"application/json"),
};
var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
throw new HttpRequestException($"{(int)response.StatusCode}: {body}");
Console.WriteLine(body);
<?php
$ch = curl_init("https://api.arsel.sa/v1/whatsapp/templates");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("ARSEL_API_KEY"),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode([
"name" => "order_shipped",
"language" => "en",
"category" => "utility",
"components" => [
[
"type" => "HEADER",
"format" => "TEXT",
"text" => "Your order",
],
[
"type" => "BODY",
"text" => "Hi {{first_name}}, order {{order_id}} has shipped.",
],
[
"type" => "FOOTER",
"text" => "Arsel Store",
],
[
"type" => "BUTTONS",
"buttons" => [
[
"type" => "URL",
"text" => "Track",
"url" => "https://example.com/track",
],
],
],
],
]),
]);
$data = json_decode(curl_exec($ch), true);
if (curl_getinfo($ch, CURLINFO_HTTP_CODE) >= 400) {
throw new Exception("{$data['name']}: {$data['message']}");
}
print_r($data);
Error Responses
- 422 Validation
- 409 Name Taken
- 400 Not Connected
- 401 Unauthorized
{
"status_code": 422,
"name": "validation_error",
"message": "Placeholders must be named in lowercase snake_case, e.g. {{first_name}}. Not accepted: {{firstName}}"
}
{
"status_code": 409,
"name": "conflict",
"message": "You already have a template called \"order_shipped\" in this language. Pick a different name, or add this content under another language."
}
{
"status_code": 400,
"name": "bad_request",
"message": "WhatsApp Business Account not connected."
}
{
"status_code": 401,
"name": "unauthorized",
"message": "Invalid API Key"
}