Working PHP for the official WhatsApp Business API: approved templates with variables, PDF and image headers, reply buttons inside the 24-hour window, error handling, and what each message costs in India after Meta's October 2026 changes. At the end, the same send made directly against Meta's Cloud API.
To send a WhatsApp message from PHP, POST the recipient number, an approved template name and its parameters to a WhatsApp Business API endpoint with your API key. With the AvanceZone API that is one curl_init() call to /api/v2/whatsapp/send. Free-form text is only allowed within 24 hours of the customer's last message; at any other time WhatsApp requires a pre-approved template. Each delivered message is billed by category, so choose templates carefully.
So the first job is getting templates approved, not writing code. Your WhatsApp Business account needs to be connected and enabled on the platform. As a Meta Tech Provider, we handle number setup and template submission during onboarding.
Suppose Meta has approved a utility template called order_shipped with the body "Hi {{1}}, your order {{2}} has been shipped. Track it here: {{3}}". Fill the placeholders in order through params:
<?php
// send-whatsapp.php: run with WA_API_KEY=xxxx php send-whatsapp.php
$payload = [
'to' => '919876543210', // country code + number
'template' => 'order_shipped', // approved template name
'language' => 'en', // the language it was approved in
'params' => ['Priya', 'A1023', 'https://avz.in/t/9KX2'], // {{1}}, {{2}}, {{3}}
];
$ch = curl_init('https://whitelabled.securesmsc.com/api/v2/whatsapp/send');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 20,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('WA_API_KEY'),
'Content-Type: application/json',
'Accept: application/json',
],
CURLOPT_POSTFIELDS => json_encode($payload),
]);
$body = curl_exec($ch);
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$res = json_decode((string) $body, true);
if ($http === 200 && ($res['status'] ?? '') === 'success') {
echo "Sent: {$res['data']['message_id']}\n"; // a WhatsApp message id (wamid...)
} else {
echo "Failed ($http): " . ($res['code'] ?? '') . ' ' . ($res['message'] ?? '') . "\n";
}A successful response looks like this. Store message_id; you can later ask GET /api/v2/whatsapp/status/{messageId} for its delivery status.
{
"status": "success",
"data": {
"message_id": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAERgSQzVGRDY0",
"id": 5521,
"to": "919876543210"
}
}Up to 20 parameters are accepted. Send them as strings in the exact order of the template's placeholders. If a template exists in more than one language, pass language (for example ta for Tamil); otherwise the approved version is used.
Invoices, tickets and lab reports usually go out as a utility template approved with a document header. Add the header fields to the same request:
<?php
$payload = [
'to' => '919876543210',
'template' => 'invoice_ready',
'params' => ['Priya', 'INV-2026-0412', '4,720'],
'header_media_url' => 'https://files.example.com/invoices/INV-2026-0412.pdf',
'header_media_type' => 'document', // or 'image' / 'video'
'header_filename' => 'Invoice-INV-2026-0412.pdf',
];The file must be reachable over public HTTPS when the message is sent, because WhatsApp fetches it. Use a signed URL that stays valid for a few hours rather than exposing a permanent public folder. If the template was approved with a media header, the header fields are required; Meta rejects the send without them.
When a customer has written to you within the last 24 hours, you can reply with plain text, media, or text with up to three reply buttons. Each button title can be at most 20 characters:
<?php
$payload = [
'to' => '919876543210',
'message' => 'Your appointment is tomorrow at 4:30 PM. Can you make it?',
'buttons' => [
['id' => 'appt_yes', 'title' => 'Yes, confirm'],
['id' => 'appt_reschedule', 'title' => 'Reschedule'],
['id' => 'appt_cancel', 'title' => 'Cancel'],
],
];
// Outside the window this returns 422 with code "template_required".Two constraints: buttons only work with a free-form message (not with a template or media), and on our shared platform number only approved templates can be sent. Free-form replies need your own connected number. Replies inside the window are service messages: the first 1,000 a month per business number are free, and since 1 October 2026 the rest are billed.
Errors come back as {"status":"error","message":"...","code":"..."}. These are the ones worth handling explicitly:
| HTTP / code | Meaning | What to do |
|---|---|---|
422 template_required | The 24-hour window is closed | Send an approved template instead |
422 shared_number_templates_only | Free-form text on the shared platform number | Use a template, or connect your own number |
422 invalid_recipient | Number missing or malformed | Store numbers with country code; fall back to SMS |
422 buttons_require_message | Buttons sent with a template or media | Buttons only with a text message |
| 404 | Template not found or not approved | Check the name and language against your approved list (GET /whatsapp/templates) |
| 402 | WhatsApp wallet balance too low for this category | Top up; alert ops, don't retry in a loop |
| 403 / 409 | WhatsApp not enabled, or no business account connected | Finish onboarding in the dashboard |
| 401 | Invalid API key | Fix configuration |
| 502 | The send to WhatsApp failed upstream | Log message, which carries the reason. Don't auto-resend |
Never resend after a timeout. If your request timed out, the message may already be on the customer's phone. Retry only when the connection was refused or DNS failed, which means the request never left your server. This is the same rule as our SMS guide.
Not everyone is on WhatsApp, and some messages, such as OTPs, can't wait. Because WhatsApp and SMS sit on the same platform and API key, a fallback is a few lines:
<?php
function notify_shipped(string $phone, string $name, string $order, string $url): string
{
$wa = api_post('/whatsapp/send', [
'to' => $phone, 'template' => 'order_shipped', 'params' => [$name, $order, $url],
]);
if (($wa['status'] ?? '') === 'success') {
return 'whatsapp';
}
// Fall back only when WhatsApp clearly did not take the message.
if (in_array($wa['code'] ?? '', ['invalid_recipient', 'template_required'], true)) {
api_post('/sms/send', [
'sender_id' => 'AVNZON',
'recipient' => $phone,
'message' => "Your order {$order} has been shipped. Track it here: {$url} -AVNZON",
'dlt_template_id' => '1107161234567890123',
]);
return 'sms';
}
return 'failed';
}
// api_post(): the curl_init() call from section 2, wrapped to return the decoded JSON.The SMS text must match a DLT-approved template, with variables tagged by type. Our Tamil Nadu WhatsApp page compares WhatsApp and SMS costs for common message types.
| Category | Typical use | Approx. Meta rate, India |
|---|---|---|
| Marketing | Offers, launches, re-engagement | ≈ ₹0.8631 per delivered message |
| Utility | Order, payment and appointment updates | ≈ ₹0.115, now also inside an open window |
| Authentication | OTPs | ≈ ₹0.115 |
| Service | Free-form replies in a customer-started chat | 1,000 free per number per month, then billed |
Add 18% GST and the platform fee. Meta changed two things on 1 October 2026: service replies stopped being unlimited, and utility templates inside an open window started being billed again. Rates are revised periodically; the full breakdown with worked examples is in WhatsApp Business API pricing in India.
If you integrate with Meta directly, the template send is a POST to the Graph API with your phone number ID and a system-user access token. Keep the API version in one constant: Meta's current version is v26.0 (July 2026), and older versions are retired on a schedule.
<?php
const GRAPH_VERSION = 'v26.0';
$phoneNumberId = getenv('WA_PHONE_NUMBER_ID');
$body = [
'messaging_product' => 'whatsapp',
'recipient_type' => 'individual',
'to' => '919876543210',
'type' => 'template',
'template' => [
'name' => 'order_shipped',
'language' => ['code' => 'en'],
'components' => [[
'type' => 'body',
'parameters' => [ // positional: same order as {{1}}, {{2}}, {{3}}
['type' => 'text', 'text' => 'Priya'],
['type' => 'text', 'text' => 'A1023'],
['type' => 'text', 'text' => 'https://avz.in/t/9KX2'],
],
// Named-parameter templates add 'parameter_name' => 'first_name' to each entry.
]],
],
];
$ch = curl_init('https://graph.facebook.com/' . GRAPH_VERSION . "/{$phoneNumberId}/messages");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('WA_ACCESS_TOKEN'),
'Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode($body),
]);
$res = json_decode((string) curl_exec($ch), true);
curl_close($ch);
// Success: $res['messages'][0]['id'] is the wamid. Errors: $res['error']['code'].Meta error codes you'll meet most often:
| Code | Meaning |
|---|---|
| 131047 | More than 24 hours since the user replied; a template is required |
| 132000 | Number of parameters doesn't match the template |
| 132001 | Template doesn't exist in that language or isn't approved |
| 131026 | Message undeliverable, for example not a WhatsApp number |
| 131049 | Not delivered to protect ecosystem engagement (marketing frequency limits) |
| 130429 / 131056 | Throughput limit reached / too many messages to one recipient |
| 190 | Access token expired |
Delivery and reply events arrive by webhook. Meta verifies your endpoint with a GET (note that PHP turns hub.mode into hub_mode) and signs every POST with your app secret, so check the signature before trusting the body:
<?php
// webhook.php
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$ok = ($_GET['hub_mode'] ?? '') === 'subscribe'
&& hash_equals((string) getenv('WA_VERIFY_TOKEN'), (string) ($_GET['hub_verify_token'] ?? ''));
http_response_code($ok ? 200 : 403);
header('Content-Type: text/plain');
echo $ok ? (string) ($_GET['hub_challenge'] ?? '') : '';
exit;
}
$raw = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $raw, (string) getenv('WA_APP_SECRET'));
$signature = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
// Queue $event for processing and return 200 quickly; Meta retries slow or failed deliveries.
http_response_code(200);Going direct means you also handle Business verification, number registration, template submission, token rotation and Meta billing yourself. Going through a provider trades a platform fee for one API covering WhatsApp and SMS, onboarding help and a single invoice. Either way, the template rules and prices above are Meta's.
Search for "send WhatsApp message using PHP free" and you'll find scripts that drive WhatsApp Web, or wrappers around unofficial gateways. They work until the number gets banned, and for business sending that usually happens quickly. You lose the number, its chat history and every customer who saved it. The official API costs money per message, but it keeps the number, adds the business display name and lets you send at scale. For a business, that is the only option that lasts.
More guides: SMS in Laravel, Python, Node.js, Java, or the developer hub. Product details are on WhatsApp Business API and, for local onboarding, WhatsApp API in Coimbatore.
We connect your number, get your templates approved and give you one API key for WhatsApp and SMS.