Copy-paste PHP code for sending SMS through an Indian DLT-compliant gateway. It starts with a ten-line cURL call, then covers a reusable client, template IDs, Tamil Unicode, bulk sends, OTPs and delivery status. Every example uses the real AvanceZone API.
To send an SMS from PHP, POST the sender ID, recipient and message to the gateway's API with your API key in an Authorization: Bearer header. In India the message must also match a DLT template approved for that sender ID. If it doesn't, the gateway rejects it before it reaches the operator. With the AvanceZone API that is one curl_init() call to /api/v2/sms/send. A successful send returns a message_id, which you use to check delivery.
Most failed integrations are not code problems. Get these four things in place first and the code part takes ten minutes:
AVNZON, or six digits for promotional ones, registered on DLT and approved on your account. Since May 2025 operators add a category suffix, so recipients see something like JM-AVNZON-S (S service, T transactional, P promotional, G government). Your code sends only the six characters.{#numeric#} or {#url#}. If you have not done this yet, start with our DLT registration guide.php -v and php -m | grep curl. The first example also runs on PHP 7.4; the client class uses PHP 8 constructor promotion.This is the smallest complete example. It reads the key from the environment, sends one transactional message and prints the decoded response.
<?php
// send-sms.php: run with SMS_API_KEY=xxxx php send-sms.php
$apiKey = getenv('SMS_API_KEY');
$payload = [
'sender_id' => 'AVNZON',
'recipient' => '919876543210', // 91 + 10-digit mobile
'message' => 'Your OTP for login is 482913. It is valid for 10 minutes. Do not share it with anyone. -AVNZON',
'route' => 'transactional',
];
$ch = curl_init('https://whitelabled.securesmsc.com/api/v2/sms/send');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $apiKey,
'Content-Type: application/json',
'Accept: application/json',
],
CURLOPT_POSTFIELDS => json_encode($payload),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($body === false) {
exit("Network error: $error\n");
}
echo "HTTP $status\n";
print_r(json_decode($body, true));The recipient field accepts a 10-digit Indian mobile number or the full international form with country code (919876543210 or +919876543210). The route is transactional by default. Set it to promotional only for marketing messages, which TRAI restricts to 9 AM–9 PM and to numbers not on the DND registry.
The API always returns JSON with a boolean success. A successful call means the message has been accepted and queued, not yet delivered:
{
"success": true,
"message": "SMS queued successfully",
"data": {
"message_id": 1849302,
"queue_id": 1849302,
"status": "queued",
"estimated_delivery": "2026-10-29T10:42:05.000000Z"
}
}Failures come back with an HTTP status that tells you what to do next:
| HTTP | Meaning | What your code should do |
|---|---|---|
| 200 | Accepted and queued | Store data.message_id against your own record |
| 401 | Invalid API key | Do not retry. Alert someone, because the key is wrong or revoked |
| 400 | Invalid sender ID: header not approved on your account | Do not retry. Fix the sender ID |
| 422 | Validation failed (bad number, empty message) or error_code: TEMPLATE_MISMATCH | Do not retry. Log errors and fix the input or template |
| 429 / 502 / 503 | Rate-limited, or the gateway was briefly unavailable and did not process the request | Retry with back-off (see below) |
| 500 / 504 | Server error or timeout. The message may already be queued | Do not resend blindly. Log it and check before retrying |
The rule that matters: only retry when retrying could succeed. A 4xx response will fail the same way every time. Retrying it burns API calls and hides a bug.
Real applications send from several places: signup, orders, password resets. Put the HTTP details in one class with sensible timeouts and a careful retry policy:
<?php
final class SmsClient
{
public function __construct(
private string $apiKey,
private string $baseUrl = 'https://whitelabled.securesmsc.com/api/v2',
private int $maxAttempts = 3
) {}
/** Send one SMS. Returns the decoded JSON; throws on non-retryable failure. */
public function send(string $to, string $message, string $senderId, array $extra = []): array
{
$payload = array_merge([
'sender_id' => $senderId,
'recipient' => $to,
'message' => $message,
], $extra); // e.g. ['route' => 'promotional', 'dlt_template_id' => '1107...', 'unicode' => true]
return $this->request('POST', '/sms/send', $payload);
}
public function status(string $messageId): array
{
return $this->request('GET', '/sms/status/' . rawurlencode($messageId));
}
private function request(string $method, string $path, ?array $payload = null): array
{
for ($attempt = 1; ; $attempt++) {
$ch = curl_init($this->baseUrl . $path);
$opts = [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $this->apiKey,
'Content-Type: application/json',
'Accept: application/json',
],
];
if ($payload !== null) {
$opts[CURLOPT_POSTFIELDS] = json_encode($payload);
}
curl_setopt_array($ch, $opts);
$body = curl_exec($ch);
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$errno = curl_errno($ch);
curl_close($ch);
// Could not even connect: the request never left, safe to retry.
$connectFailed = in_array($errno, [CURLE_COULDNT_RESOLVE_HOST, CURLE_COULDNT_CONNECT], true);
// 429/502/503 mean "not processed, try again". A 500 or 504 on a send
// may arrive after the message was queued, so only GETs retry those.
$retryable = $connectFailed
|| in_array($http, [429, 502, 503], true)
|| ($method === 'GET' && $http >= 500);
if (!$retryable || $attempt >= $this->maxAttempts) {
$data = is_string($body) ? json_decode($body, true) : null;
if ($http >= 200 && $http < 300 && is_array($data)) {
return $data;
}
$reason = $data['message'] ?? ($errno ? curl_strerror($errno) : "HTTP $http");
throw new RuntimeException("SMS API error: $reason", $http);
}
usleep((2 ** $attempt) * 250000); // 0.5s, 1s, 2s ...
}
}
}
// Usage
$sms = new SmsClient(getenv('SMS_API_KEY'));
$res = $sms->send('919876543210',
'Your order #A1023 has been shipped and will reach you by 31 Oct. -AVNZON',
'AVNZON');
echo $res['data']['message_id'];Why it does not retry on timeouts. If the connection opened and then timed out, the gateway may already have accepted the message. Retrying blindly is how customers end up with the same OTP three times. The class above retries a send only when the request provably never arrived (DNS or connect failure) or the gateway said it did not process it (429, 502 or 503). A 500 or 504 on a send is logged, not retried.
Every commercial SMS in India is checked against the DLT template registered for its sender ID. Since TRAI's 18 November 2025 direction, every variable in that template is tagged with its type. Say your approved template on Airtel's portal is:
Your order {#alphanumeric#} has been shipped. Track it here: {#url#} -AVNZONYour code may only replace the tagged slots, and each value must fit its tag's limit. Keep templates in one place and fill them with a function that refuses bad input rather than quietly cutting it:
<?php
const TPL_SHIPPED = 'Your order {#alphanumeric#} has been shipped. Track it here: {#url#} -AVNZON';
// Typical limits per tag (Airtel names; Vi/BSNL use {#num#}, {#alp#}, {#urg#} ...).
// Confirm against your operator's portal.
const DLT_TAG_MAX = ['url' => 120, 'urlott' => 120, 'urg' => 120, 'uro' => 120, 'var' => 30];
const DLT_DEFAULT_MAX = 40;
function fill_template(string $template, array $values): string
{
$i = 0;
$out = preg_replace_callback('/\{#(\w+)#\}/', function (array $m) use (&$i, $values) {
if (!array_key_exists($i, $values)) {
throw new InvalidArgumentException('Not enough values for template');
}
$value = (string) $values[$i++];
$max = DLT_TAG_MAX[$m[1]] ?? DLT_DEFAULT_MAX;
if (mb_strlen($value) > $max) {
// Never truncate: a cut-off link or amount is worse than a failed send.
throw new LengthException("Value for {#{$m[1]}#} is longer than $max characters");
}
return $value;
}, $template);
if ($i !== count($values)) {
throw new InvalidArgumentException('Too many values for template');
}
return $out;
}
$message = fill_template(TPL_SHIPPED, ['A1023', 'https://avz.in/t/9KX2']);
$sms->send('919876543210', $message, 'AVNZON', [
'dlt_template_id' => '1107161234567890123', // from your DLT portal
]);Variable tagging has been mandatory since TRAI's 18 November 2025 direction. Existing templates had 60 days to be converted, after which messages on non-compliant templates are rejected. Tag names differ by operator: Airtel uses {#numeric#}, {#url#}, {#cbn#}, while Vi and BSNL use {#num#}, {#urg#}, {#cbn#}. Any URL or callback number you insert must also be whitelisted on DLT; non-whitelisted links have been blocked since 1 October 2024. Our variable tagging guide has the full mapping, and DLT error codes explains rejections such as 635 (variable exceeded max length).
Passing dlt_template_id is optional if the template is registered on your account, but it makes failures easier to trace. Your DLT entity (PE) ID is taken from your account automatically; pass dlt_entity_id only if you send for more than one registered entity.
As soon as a message contains a single character outside the GSM-7 alphabet, the whole message is encoded as UCS-2 and the per-part limit drops from 160 to 70 characters. That includes Tamil, Hindi, curly quotes pasted from Word, and the ₹ symbol. Set 'unicode' => true for these messages, and count parts before you send so the cost does not surprise you:
| Encoding | Single SMS | Per part when split | Triggered by |
|---|---|---|---|
| GSM-7 | 160 chars | 153 chars | Plain English letters, digits, common punctuation |
| UCS-2 (Unicode) | 70 chars | 67 chars | Any Tamil/Hindi letter, emoji, ₹, curly quotes |
<?php
/** Returns ['encoding' => 'GSM-7'|'UCS-2', 'length' => int, 'parts' => int]. */
function sms_parts(string $text): array
{
$basic = '@£$¥èéùìòÇØøÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?¡'
. 'ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà' . "\n\r";
$extended = '^{}\\[~]|€' . "\f"; // these cost 2 characters each in GSM-7
$length = 0;
foreach (mb_str_split($text) as $ch) {
if (mb_strpos($basic, $ch) !== false) { $length += 1; continue; }
if (mb_strpos($extended, $ch) !== false) { $length += 2; continue; }
// Anything else forces UCS-2: count UTF-16 code units (emoji = 2).
$units = intdiv(strlen(mb_convert_encoding($text, 'UTF-16BE', 'UTF-8')), 2);
return ['encoding' => 'UCS-2', 'length' => $units,
'parts' => $units <= 70 ? 1 : (int) ceil($units / 67)];
}
return ['encoding' => 'GSM-7', 'length' => $length,
'parts' => $length <= 160 ? 1 : (int) ceil($length / 153)];
}
print_r(sms_parts('உங்கள் OTP 482913. 10 நிமிடங்கள் செல்லுபடியாகும். -AVNZON'));
// [encoding] => UCS-2 [length] => 57 [parts] => 1Tamil text is longer than it looks. Vowel signs are separate code points, so செல்லுபடியாகும் is 15 characters, not 7. Count in code, not by eye.
For the same message to many people, use /sms/send-bulk with a recipients array of up to 1,000 numbers per call, and chunk anything larger:
<?php
$numbers = array_unique($numbersFromDatabase); // de-duplicate first
$message = 'Our Sulur showroom is open this Sunday, 10 AM to 6 PM. -AVNZON';
foreach (array_chunk($numbers, 1000) as $batch) {
$ch = curl_init('https://whitelabled.securesmsc.com/api/v2/sms/send-bulk');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('SMS_API_KEY'),
'Content-Type: application/json',
'Accept: application/json'],
CURLOPT_POSTFIELDS => json_encode([
'sender_id' => 'AVNZON',
'recipients' => array_values($batch),
'message' => $message,
]),
]);
$result = json_decode(curl_exec($ch), true);
curl_close($ch);
// log $result per batch so a failed chunk can be re-sent on its own
}Run large sends from a queue worker or a CLI script, not from a web request that a browser can time out. If you do not need code at all for a one-off campaign, upload a CSV in the bulk SMS panel instead.
Store the message_id from the send response, then ask for its status. The status moves from queued to sent and ends at delivered or failed:
<?php
$status = $sms->status('1849302');
echo $status['data']['status']; // queued | sent | delivered | failedDo not poll in a tight loop. For production volumes, register a delivery-report webhook in the dashboard and let the gateway call you. Use polling for support screens ("did my OTP go out?") and for reconciling the odd message whose callback you missed.
The SMS call is the easy part of OTP login. These are the parts that get abused:
<?php
// 1. Rate-limit BEFORE generating anything (per phone and per IP).
if (otp_requests_last_hour($phone) >= 5) {
http_response_code(429);
exit('Too many requests. Try again later.');
}
// 2. Unpredictable 6-digit code; store only a hash, with an expiry.
$otp = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
save_otp($phone, password_hash($otp, PASSWORD_DEFAULT), time() + 600);
// 3. Send on the transactional route with the approved OTP template.
$sms->send($phone,
"Your OTP for login is {$otp}. It is valid for 10 minutes. Do not share it with anyone. -AVNZON",
'AVNZON');
// 4. On verify: check expiry, password_verify(), cap attempts at 5, then delete the row.Never write the plain OTP to logs, and never return it in an API response "for testing". Both are common in audits. For high-volume login traffic, our OTP SMS service runs on priority routes with sub-5-second delivery.
| Symptom | Likely cause | Fix |
|---|---|---|
401 Invalid API key | Key missing, wrong or revoked; often an empty getenv() under PHP-FPM | Check the variable is set in the FPM pool or .env, not just your shell |
400 Invalid sender ID | Header not approved on your account, or wrong case | Use the exact six-character header shown in your dashboard |
TEMPLATE_MISMATCH | Text differs from the approved DLT template | Fill the template with fill_template(); watch double spaces and trailing full stops |
| Accepted but never delivered | Promotional route to a DND number, or outside 9 AM–9 PM | Use the transactional route for service messages; schedule promotions |
| Tamil text arrives as ???? | Sent without unicode, or the source file is not UTF-8 | Save files as UTF-8 and pass 'unicode' => true |
| cURL error 60 (SSL) | Outdated CA bundle on the server | Update ca-certificates; never set CURLOPT_SSL_VERIFYPEER to false |
| Duplicate messages | Retrying after a timeout | Retry sends only on connect failures, 429, 502 and 503, as in the class above |
Using another language? The same API from Python, Node.js, Java / Spring Boot and Laravel is covered in our developer guides.
Open an account, and we help with DLT registration and template approval so your PHP code has something approved to send.