Working Python code for an Indian DLT-compliant SMS gateway. It starts with a single requests.post() call and then adds the parts that matter in production: retries that cannot send a message twice, a typed client, DLT template filling, Tamil segment counts, bulk sends, Celery tasks and OTPs. Every example uses the real AvanceZone API.
To send an SMS from Python, POST the sender ID, recipient and message as JSON to the gateway with requests. Put your API key in an Authorization: Bearer header and always set a timeout. In India the text must also match a DLT template approved for that sender ID, or the gateway rejects it with TEMPLATE_MISMATCH. With AvanceZone this is one requests.post() to /api/v2/sms/send. A successful call returns a message_id, which you then use to check delivery.
The code is the short part. Most failed integrations we see are missing one of these:
AVNZON. Promotional headers are usually six digits. Since May 2025 operators add a category suffix (-T, -S, -P or -G), so the handset shows something like VM-AVNZON-T. Your code still sends the plain six-character header.pip install requests. The retry code needs urllib3 1.26 or newer, which any current requests release pulls in.The smallest complete example. Passing json= serialises the payload and sets Content-Type: application/json for you.
# send_sms.py: run with SMS_API_KEY=xxxx python send_sms.py
import os
import requests
resp = requests.post(
"https://whitelabled.securesmsc.com/api/v2/sms/send",
json={
"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",
},
headers={
"Authorization": f"Bearer {os.environ['SMS_API_KEY']}",
"Accept": "application/json",
},
timeout=(5, 15), # (connect, read) seconds
)
print(resp.status_code, resp.json())Two details are easy to miss. First, requests has no default timeout. Without timeout=, a stalled connection can hang a worker for good. Second, recipient accepts 919876543210, +919876543210 or the bare 10-digit number. Strip spaces and leading zeros from user input before you send.
Every response is JSON with a boolean success. A 200 means the gateway accepted and queued the message. It has not been delivered yet.
{
"success": true,
"message": "SMS queued successfully",
"data": {
"message_id": 1849302,
"queue_id": 1849302,
"status": "queued",
"estimated_delivery": "2026-10-29T10:42:05.000000Z"
}
}| HTTP | Body | What your code should do |
|---|---|---|
| 200 | success: true | Save data["message_id"] with your own record |
| 401 | "message": "Invalid API key" | Do not retry. The key is wrong, missing or revoked |
| 400 | "error": "Invalid sender ID" | Do not retry. Use a header approved on your account |
| 422 | Validation failed with an errors dict, or error_code: TEMPLATE_MISMATCH | Do not retry. Log the details and fix the input or template |
| 429, 502, 503 | Rate limit, or the gateway is briefly unavailable | Retry with back-off |
| 500, 504 | Server error or gateway timeout | On a send, do not retry automatically: the message may already be queued. Check before resending |
Most Python SMS tutorials either never retry or wrap the call in a bare for loop. The loop is worse. If the gateway accepted your request but the response was slow, the loop sends the same OTP again. The right policy has three parts:
urllib3's Retry, mounted on a requests.Session, expresses exactly that policy:
# sms_client.py
from __future__ import annotations
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
BASE_URL = "https://whitelabled.securesmsc.com/api/v2"
def make_session(api_key: str) -> requests.Session:
retry = Retry(
total=None, # use the per-category limits below
connect=3, # DNS failure / refused / connect timeout: never sent
read=False, # once the request is on the wire, never resend it
other=0,
status=3, # 429/502/503: refused before processing, try again
status_forcelist=(429, 502, 503), # NOT 500/504: the SMS may already be queued
allowed_methods=frozenset({"GET", "POST"}), # POST is NOT retried by default
backoff_factor=0.5,
respect_retry_after_header=True,
raise_on_status=False, # return the last response instead of raising
)
session = requests.Session()
session.mount("https://", HTTPAdapter(max_retries=retry))
session.headers.update({
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
})
return sessionWe ran this configuration against a local test server before publishing. With urllib3 2.0:
Retry-After: 1 was retried once, after one second, and then succeeded.SmsUncertainError after a single request.Why read=False and not read=0? Both stop the retry. With read=0, though, urllib3 wraps the timeout in MaxRetryError, and requests raises it as a ConnectionError. Your code then cannot tell "never connected" from "sent, but no answer". With read=False you get a plain requests.ReadTimeout, which is the signal you need.
Wrap the session in a small class so the rest of your code deals with three exceptions, not with HTTP. SmsTemporaryError means "not sent, try later". SmsUncertainError means "may have been sent, check before resending". Anything else under SmsError is a permanent rejection.
# sms_client.py (continued)
from dataclasses import dataclass
from typing import Any, Optional
from urllib3.exceptions import NewConnectionError
class SmsError(Exception):
"""The gateway refused the request. Retrying will not help."""
def __init__(self, http_status: int, body: dict):
self.http_status = http_status
self.error = body.get("error", "HTTP error")
self.error_code = body.get("error_code") # e.g. "TEMPLATE_MISMATCH"
self.details = body.get("errors") or body.get("details") or {}
super().__init__(f"HTTP {http_status}: {self.error}: {body.get('message', '')}")
class SmsTemporaryError(SmsError):
"""Not sent, and safe to try again later (429, 502, 503, or no connection)."""
class SmsUncertainError(Exception):
"""The request may have reached the gateway. Check status before resending."""
@dataclass(frozen=True)
class Queued:
message_id: int
status: str
estimated_delivery: Optional[str] = None
def _never_sent(exc: requests.RequestException) -> bool:
if isinstance(exc, requests.ConnectTimeout):
return True
reason = getattr(exc.args[0] if exc.args else None, "reason", None)
return isinstance(reason, NewConnectionError) # DNS failure, connection refused
class SmsClient:
def __init__(self, api_key: str, base_url: str = BASE_URL,
timeout: tuple[float, float] = (5, 15)):
self.base_url = base_url.rstrip("/")
self.timeout = timeout # (connect, read) seconds
self.session = make_session(api_key)
def send(self, to: str, message: str, sender_id: str, **extra: Any) -> Queued:
# extra: dlt_template_id="1107...", unicode=True, route="promotional", ...
payload = {"sender_id": sender_id, "recipient": to, "message": message, **extra}
data = self._request("POST", "/sms/send", json=payload)["data"]
return Queued(data["message_id"], data["status"], data.get("estimated_delivery"))
def send_bulk(self, recipients: list[str], message: str, sender_id: str, **extra: Any) -> dict:
payload = {"sender_id": sender_id, "recipients": recipients, "message": message, **extra}
return self._request("POST", "/sms/send-bulk", json=payload)
def status(self, message_id: int | str) -> str:
return self._request("GET", f"/sms/status/{message_id}")["data"]["status"]
def balance(self) -> dict:
return self._request("GET", "/balance")["data"]
def _request(self, method: str, path: str, **kwargs: Any) -> dict:
try:
resp = self.session.request(method, self.base_url + path,
timeout=self.timeout, **kwargs)
except requests.RequestException as exc:
if _never_sent(exc):
raise SmsTemporaryError(0, {"error": "Connection failed", "message": str(exc)}) from exc
raise SmsUncertainError(str(exc)) from exc
try:
body = resp.json()
except ValueError:
body = {"error": f"Non-JSON response ({resp.status_code})"}
if resp.ok and body.get("success"):
return body
if resp.status_code in (429, 502, 503) or (method == "GET" and resp.status_code >= 500):
raise SmsTemporaryError(resp.status_code, body)
if resp.status_code >= 500: # 500/504 on a send: may be queued
raise SmsUncertainError(f"HTTP {resp.status_code} from {path}; check before resending")
raise SmsError(resp.status_code, body)Usage is one line: SmsClient(os.environ["SMS_API_KEY"]).send("919876543210", text, "AVNZON"). Create one client per process and reuse it. The session keeps the TLS connection open, so the second and later sends skip the handshake.
Every commercial SMS in India is matched against the DLT template registered for its header. Only the variable slots may change. Four rules decide whether a filled template gets through in 2026:
{#numeric#}, {#alphanumeric#}, {#url#}, {#urlott#}, {#cbn#} and {#email#}. Vi and BSNL use {#num#}, {#alp#}, {#urg#}, {#uro#}, {#cbn#} and {#eml#}. Our DLT variable tagging guide maps them.{#var#} allows 30. Check your operator's portal, because the sources do not all agree. A number tag must contain digits only.Keep templates as constants and fill them with one helper. Never build the sentence with f-strings scattered across views:
# templates.py
import re
TPL_APPOINTMENT = "Dear {#alphanumeric#}, your appointment on {#alphanumeric#} is confirmed. -AVNZON"
PLACEHOLDER = re.compile(r"\{#(\w+)#\}") # {#var#}, {#numeric#}, {#num#}, {#url#} ...
MAX_LEN = {"var": 30, "url": 120, "urlott": 120, "urg": 120, "uro": 120} # others: 40
DIGITS_ONLY = {"numeric", "num", "number"}
def fill_template(template: str, *values: object) -> str:
slots = PLACEHOLDER.findall(template)
if len(values) != len(slots):
raise ValueError(f"template has {len(slots)} variables, got {len(values)}")
filled = iter(values)
def replace(match: re.Match) -> str:
tag, value = match.group(1), str(next(filled))
limit = MAX_LEN.get(tag, 40)
if len(value) > limit: # never truncate: a cut URL is broken
raise ValueError(f"{{#{tag}#}} value is {len(value)} chars; limit is {limit}")
if tag in DIGITS_ONLY and not value.isdigit():
raise ValueError(f"{{#{tag}#}} must be digits only, got {value!r}")
return value
return PLACEHOLDER.sub(replace, template)
print(fill_template(TPL_APPOINTMENT, "Priya", "31 Oct 4.30 PM"))
# Dear Priya, your appointment on 31 Oct 4.30 PM is confirmed. -AVNZONThe helper raises an error instead of trimming. A shortened name might still pass, but a shortened tracking link breaks, and you want to see that in your logs rather than hear it from a customer. Because the helper passes a function to re.sub, a value containing \1 or {#var#} is inserted literally. Copy each template from your DLT portal exactly, tags included. If the gateway still answers TEMPLATE_MISMATCH, our DLT error codes guide lists the usual causes.
Pass dlt_template_id="1107…" in send() when you have it. It is optional if the template is registered on your account, but it makes rejections much easier to trace. Your entity (PE) ID comes from your account automatically. Pass dlt_entity_id only if you send for more than one registered entity.
One character outside the GSM-7 alphabet switches the whole message to UCS-2, and the per-part limit drops from 160 to 70. That character can be Tamil or Hindi, an emoji, the ₹ sign or a curly quote pasted from Word. Send these messages with unicode=True, and count parts before you send, because each part is billed.
| Encoding | Single SMS | Per part when split | Triggered by |
|---|---|---|---|
| GSM-7 | 160 chars | 153 chars | English letters, digits, common punctuation (^{}[]~|\€ count as 2) |
| UCS-2 | 70 chars | 67 chars | Any Tamil/Hindi letter, emoji, ₹, curly quotes |
# sms_parts.py
GSM7_BASIC = frozenset(
"@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !\"#¤%&'()*+,-./0123456789:;<=>?"
"¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà"
)
GSM7_EXTENDED = frozenset("^{}\\[~]|€\f") # each costs 2 characters
def sms_parts(text: str) -> dict:
if all(ch in GSM7_BASIC or ch in GSM7_EXTENDED for ch in text):
length = sum(2 if ch in GSM7_EXTENDED else 1 for ch in text)
encoding, single, per_part = "GSM-7", 160, 153
else:
# UCS-2 counts UTF-16 code units; len() counts code points (emoji differ)
length = len(text.encode("utf-16-le")) // 2
encoding, single, per_part = "UCS-2", 70, 67
parts = 1 if length <= single else -(-length // per_part) # ceiling division
return {"encoding": encoding, "length": length, "parts": parts}
print(sms_parts("Your OTP for login is 482913. It is valid for 10 minutes. Do not share it with anyone. -AVNZON"))
# {'encoding': 'GSM-7', 'length': 94, 'parts': 1}
print(sms_parts("உங்கள் OTP 482913. 10 நிமிடங்கள் செல்லுபடியாகும். -AVNZON"))
# {'encoding': 'UCS-2', 'length': 57, 'parts': 1}
print(sms_parts("Order shipped 📦 Track: avancezone.com/t/A1023 -AVNZON"))
# {'encoding': 'UCS-2', 'length': 54, 'parts': 1} (len() says 53: the emoji is 2 units)
print(len("செல்லுபடியாகும்")) # 15 code points for 7 visible lettersThe outputs in the comments are what the code prints on Python 3.12. Tamil is longer than it looks because vowel signs and the virama are separate code points. Count in code, not by eye. The counter can be off by one at a part boundary when a two-unit GSM extension character would straddle it. Leave a character or two of headroom in long templates.
For the same text to many numbers, /sms/send-bulk takes a recipients list of up to 1,000 numbers per call. De-duplicate first, chunk anything larger, and keep failed chunks so you can resend just those:
# bulk.py
import logging
from itertools import islice
from typing import Any, Iterable, Iterator
from sms_client import SmsClient, SmsError, SmsUncertainError
log = logging.getLogger("sms.bulk")
def chunks(items: Iterable[str], size: int = 1000) -> Iterator[list[str]]:
it = iter(items)
while batch := list(islice(it, size)): # itertools.batched() on Python 3.12+
yield batch
def send_to_many(client: SmsClient, numbers: Iterable[str], message: str,
sender_id: str = "AVNZON", **extra: Any) -> list[list[str]]:
unique = list(dict.fromkeys(numbers)) # de-duplicate, keep order
failed = []
for batch in chunks(unique):
try:
result = client.send_bulk(batch, message, sender_id, **extra)
log.info("batch of %d accepted: %s", len(batch), result.get("message"))
except (SmsError, SmsUncertainError) as exc:
log.error("batch of %d failed: %s", len(batch), exc)
failed.append(batch) # investigate, then re-send only these
return failedRun this from a management command or a worker, never inside a web request. Marketing messages belong on route="promotional" with your numeric promotional header. TRAI limits them to 9 AM–9 PM, and they do not reach numbers on the DND registry. For a one-off campaign with no code at all, upload a CSV in the bulk SMS panel.
Status moves from queued to sent and ends at delivered or failed. Poll with growing gaps, and only where someone is waiting on the answer:
# status.py
import time
FINAL = {"delivered", "failed"}
def wait_for_delivery(client, message_id: int, timeout: float = 120.0) -> str:
"""Poll with growing gaps. For support screens and reconciliation, not every message."""
deadline = time.monotonic() + timeout
delay = 2.0
while True:
status = client.status(message_id) # queued | sent | delivered | failed
if status in FINAL or time.monotonic() + delay > deadline:
return status
time.sleep(delay)
delay = min(delay * 2, 30.0)At production volume, register a delivery-report URL in the dashboard and let the gateway call you. The payload includes the message ID and its final status. Answer quickly with a 200, store the raw body, and update your records in a background job. Use polling for "did my OTP go out?" screens and to reconcile the occasional callback you missed.
In Django, read the key in settings.py so a missing variable fails at startup and not on the first password reset. Then share one client per process:
# settings.py
import os
SMS_API_KEY = os.environ["SMS_API_KEY"] # fail at startup, not at first send
SMS_SENDER_ID = os.environ.get("SMS_SENDER_ID", "AVNZON")
# sms/service.py
from functools import lru_cache
from django.conf import settings
from .sms_client import SmsClient
@lru_cache(maxsize=1)
def get_sms_client() -> SmsClient:
return SmsClient(settings.SMS_API_KEY) # one Session, reused per processSend notifications such as order updates and reminders from a Celery task, so a slow network never holds up a request. The exception classes from section 5 let Celery retry only what is safe:
# sms/tasks.py
from __future__ import annotations
import logging
from celery import shared_task
from django.conf import settings
from .service import get_sms_client
from .sms_client import SmsError, SmsTemporaryError, SmsUncertainError
log = logging.getLogger(__name__)
@shared_task(
bind=True,
autoretry_for=(SmsTemporaryError,), # only "provably not sent" failures
retry_backoff=30, # waits up to 30 s, 60 s, 120 s ... (jittered)
retry_backoff_max=600,
max_retries=5,
acks_late=False, # a worker crash must not replay a sent SMS
)
def send_sms_task(self, to: str, message: str, template_id: str | None = None) -> int | None:
extra = {"dlt_template_id": template_id} if template_id else {}
try:
queued = get_sms_client().send(to, message, settings.SMS_SENDER_ID, **extra)
except SmsUncertainError:
log.warning("SMS to %s may have been sent; not retrying", to[-4:])
return None
except SmsTemporaryError:
raise # autoretry_for handles it
except SmsError as exc:
log.error("SMS rejected: %s %s", exc.error_code or exc.error, exc.details)
return None
return queued.message_idCall it with send_sms_task.delay("919876543210", fill_template(TPL_APPOINTMENT, ...)). The except SmsTemporaryError clause has to come before except SmsError, because it is a subclass. acks_late=False is Celery's default, but spell it out: with late acknowledgement, a worker that dies just after the send gets the task redelivered, and the customer gets the message twice. On Flask or FastAPI the pattern is the same. Build one SmsClient at app start-up, store it on the app or in a dependency, and hand bulk work to Celery or RQ.
Sending the code is easy. Defending the endpoint that sends it is the real work. Attackers use public OTP forms to pump SMS to premium numbers, and they brute-force 6-digit codes. This version uses Django's cache, which should be Redis in production:
# sms/otp.py
import hashlib
import hmac
import os
import secrets
from django.conf import settings
from django.core.cache import cache
from .service import get_sms_client
from .templates import fill_template
TPL_OTP = "Your OTP for login is {#numeric#}. It is valid for 10 minutes. Do not share it with anyone. -AVNZON"
OTP_TTL = 600 # seconds
MAX_GUESSES = 5
PEPPER = os.environ["OTP_PEPPER"].encode() # server-side secret, never stored in the cache/DB
def allow(key: str, limit: int, window: int) -> bool:
cache.add(key, 0, timeout=window) # opens the window only if the key is new
return cache.incr(key) <= limit
def otp_digest(phone: str, code: str) -> str:
return hmac.new(PEPPER, f"{phone}:{code}".encode(), hashlib.sha256).hexdigest()
def issue_otp(phone: str, ip: str) -> bool:
# 1. Rate-limit before doing anything else: per number and per client IP.
if not (allow(f"otp:n:{phone}", 3, 600) and allow(f"otp:ip:{ip}", 20, 3600)):
return False
# 2. Six digits from the OS CSPRNG (never the random module); store only an HMAC.
code = f"{secrets.randbelow(1_000_000):06d}"
cache.set(f"otp:{phone}", otp_digest(phone, code), timeout=OTP_TTL)
# 3. Send inline, not via Celery: a person is waiting for this one.
get_sms_client().send(phone, fill_template(TPL_OTP, code), settings.SMS_SENDER_ID)
return True
def verify_otp(phone: str, code: str) -> bool:
if not allow(f"otp:try:{phone}", MAX_GUESSES, OTP_TTL):
return False # too many guesses: wait for the window to pass
stored = cache.get(f"otp:{phone}")
if stored is None or not hmac.compare_digest(stored, otp_digest(phone, code)):
return False
cache.delete_many([f"otp:{phone}", f"otp:try:{phone}"]) # single use
return TrueWhy these choices:
hmac.compare_digest compares in constant time, so response timing reveals nothing about how close a guess was.Never log the plain code and never return it in an API response "for testing". For high-volume login traffic, our OTP SMS service runs on priority transactional routes.
| Symptom | Likely cause | Fix |
|---|---|---|
KeyError: 'SMS_API_KEY' | Variable set in your shell but not for gunicorn, uWSGI, systemd or the Celery worker | Set it in the service unit or .env loader that starts the process |
401 Invalid API key | Wrong or revoked key, or a trailing newline copied into .env | .strip() the value; regenerate the key if it leaked |
400 Invalid sender ID | Header not approved on your account, or wrong case | Use the exact header listed by GET /api/v2/sender-ids |
TEMPLATE_MISMATCH | Text differs from the approved DLT template, or a variable failed its tag check | Fill with fill_template(); see our DLT error codes guide |
422 on recipient | Spaces, dashes or a leading 0 in the number | Keep digits only and send 91 + 10 digits |
SSLError: CERTIFICATE_VERIFY_FAILED | Outdated certifi or system CA bundle | pip install -U certifi; never pass verify=False |
SmsUncertainError / ReadTimeout | Request sent, but no clear answer: a timeout, a 500 or a 504 | Do not resend blindly. Check the dashboard or status, then decide |
Tamil arrives as ???? | Sent without unicode=True, or a CSV read without encoding="utf-8" | Open files as UTF-8 and pass unicode=True |
| Same SMS delivered twice | A retry loop around post(), or Celery acks_late=True | Use the session and task settings above |
| Accepted, never delivered | Promotional route to a DND number, or sent outside 9 AM–9 PM | Transactional route for service messages; schedule promotions |
Using another stack? The same API is covered for PHP, Laravel, Node.js and Java / Spring Boot. All of them are listed in our developer guides.
Open an account and we help with DLT registration and template approval, so your code has an approved header and template from day one.