Since 1998 5000+ Clients 99.9% Uptime TRAI Certified
🐘 DEVELOPER GUIDE · PHP

Send SMS Using PHP in India

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.

📅 Updated 29 Oct 2026⏱️ 12 min read🧪 PHP 8.0+ with the cURL extension

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.

1. Before you write any code

Most failed integrations are not code problems. Get these four things in place first and the code part takes ten minutes:

  • An API key. Create one in the dashboard of your SMS gateway account. Keep it in an environment variable, never in the repository.
  • An approved sender ID (header). Six letters for transactional and service messages, such as 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.
  • An approved DLT content template. The exact wording of each message type, with each variable tagged by type, such as {#numeric#} or {#url#}. If you have not done this yet, start with our DLT registration guide.
  • PHP 8.0 or later with the cURL extension. Check with php -v and php -m | grep curl. The first example also runs on PHP 7.4; the client class uses PHP 8 constructor promotion.

2. Send one SMS with cURL

This is the smallest complete example. It reads the key from the environment, sends one transactional message and prints the decoded response.

PHPsend-sms.php
<?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.

3. Read the response properly

The API always returns JSON with a boolean success. A successful call means the message has been accepted and queued, not yet delivered:

JSONHTTP 200
{
  "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:

HTTPMeaningWhat your code should do
200Accepted and queuedStore data.message_id against your own record
401Invalid API keyDo not retry. Alert someone, because the key is wrong or revoked
400Invalid sender ID: header not approved on your accountDo not retry. Fix the sender ID
422Validation failed (bad number, empty message) or error_code: TEMPLATE_MISMATCHDo not retry. Log errors and fix the input or template
429 / 502 / 503Rate-limited, or the gateway was briefly unavailable and did not process the requestRetry with back-off (see below)
500 / 504Server error or timeout. The message may already be queuedDo 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.

4. A reusable SmsClient class

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:

PHPSmsClient.php
<?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.

5. DLT templates in code

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:

DLT template
Your order {#alphanumeric#} has been shipped. Track it here: {#url#} -AVNZON

Your 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:

PHPtemplates.php
<?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.

6. Tamil, Hindi and other Unicode SMS

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:

EncodingSingle SMSPer part when splitTriggered by
GSM-7160 chars153 charsPlain English letters, digits, common punctuation
UCS-2 (Unicode)70 chars67 charsAny Tamil/Hindi letter, emoji, ₹, curly quotes
PHPsms-parts.php
<?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] => 1

Tamil text is longer than it looks. Vowel signs are separate code points, so செல்லுபடியாகும் is 15 characters, not 7. Count in code, not by eye.

7. Bulk sends from PHP

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:

PHPbulk.php
<?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.

8. Check delivery status

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
<?php
$status = $sms->status('1849302');
echo $status['data']['status'];      // queued | sent | delivered | failed

Do 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.

9. Sending OTP SMS safely from PHP

The SMS call is the easy part of OTP login. These are the parts that get abused:

PHPotp.php
<?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.

10. Troubleshooting PHP SMS integrations

SymptomLikely causeFix
401 Invalid API keyKey missing, wrong or revoked; often an empty getenv() under PHP-FPMCheck the variable is set in the FPM pool or .env, not just your shell
400 Invalid sender IDHeader not approved on your account, or wrong caseUse the exact six-character header shown in your dashboard
TEMPLATE_MISMATCHText differs from the approved DLT templateFill the template with fill_template(); watch double spaces and trailing full stops
Accepted but never deliveredPromotional route to a DND number, or outside 9 AM–9 PMUse the transactional route for service messages; schedule promotions
Tamil text arrives as ????Sent without unicode, or the source file is not UTF-8Save files as UTF-8 and pass 'unicode' => true
cURL error 60 (SSL)Outdated CA bundle on the serverUpdate ca-certificates; never set CURLOPT_SSL_VERIFYPEER to false
Duplicate messagesRetrying after a timeoutRetry 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.

FAQ

Sending SMS from PHP: FAQ

Can I send SMS from PHP without an API?
Not to Indian mobile numbers in any practical, legal way for business messages. Every commercial SMS in India has to pass through a DLT-registered gateway with an approved sender ID and template. A GSM modem driven by AT commands can send a handful of personal messages, but operators block bulk or templated traffic from SIM cards. For anything an application sends, use an SMS gateway API.
Why does my PHP code get a TEMPLATE_MISMATCH error?
The message text does not match any DLT template approved for that sender ID. Only the tagged variable slots may change; spaces, punctuation and the brand name must be identical to the approved template. Copy the template from your DLT portal and replace only the variables.
How many characters can one SMS hold?
A plain English (GSM-7) SMS holds 160 characters, or 153 per part once it is split into multiple parts. A Unicode SMS, which includes any Tamil, Hindi or emoji character, holds 70 characters, or 67 per part. You are billed per part.
Should I use form fields or JSON when calling the API from PHP?
Either works with the AvanceZone API. JSON is cleaner once you send arrays (bulk recipients), so the examples on this page use JSON with a Content-Type: application/json header.
How do I send OTP SMS from PHP securely?
Generate the code with random_int(), store only a hash of it with an expiry of 5 to 10 minutes, rate-limit requests per phone number and per IP, and send it on a transactional route with an approved OTP template. Never log the plain OTP.
Do you provide a PHP SDK?
The API is a single authenticated HTTPS call, so the reusable class on this page is all most projects need. If you are on Laravel, see our Laravel SMS integration guide for a notification channel.
READY TO INTEGRATE?

Get an API Key and Send Your First SMS Today

Open an account, and we help with DLT registration and template approval so your PHP code has something approved to send.