Since 1998 5000+ Clients 99.9% Uptime TRAI Certified
🐍 DEVELOPER GUIDE · PYTHON

Send SMS Using Python in India

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.

📅 Updated 29 Oct 2026⏱️ 14 min read🧪 Python 3.9+ with requests 2.x and urllib3 1.26+

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.

1. Before you write any code

The code is the short part. Most failed integrations we see are missing one of these:

  • An API key from the dashboard of your SMS gateway account. Read it from an environment variable. Never commit it, and never paste it into a notebook you share.
  • An approved sender ID (header). Transactional and service headers are six letters, such as 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.
  • An approved DLT content template for every message type. If you have not registered yet, start with our DLT registration guide.
  • Python 3.9 or later and pip install requests. The retry code needs urllib3 1.26 or newer, which any current requests release pulls in.

2. Send one SMS with requests

The smallest complete example. Passing json= serialises the payload and sets Content-Type: application/json for you.

Pythonsend_sms.py
# 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.

3. Read the response

Every response is JSON with a boolean success. A 200 means the gateway accepted and queued the message. It has not been delivered yet.

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"
  }
}
HTTPBodyWhat your code should do
200success: trueSave 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
422Validation failed with an errors dict, or error_code: TEMPLATE_MISMATCHDo not retry. Log the details and fix the input or template
429, 502, 503Rate limit, or the gateway is briefly unavailableRetry with back-off
500, 504Server error or gateway timeoutOn a send, do not retry automatically: the message may already be queued. Check before resending

4. Retries that never double-send

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:

  • Retry connection failures. If DNS failed or the connection was refused, nothing reached the gateway, so trying again is safe.
  • Retry 429, 502 and 503. The gateway, or the proxy in front of it, refused the request before processing it and asked you to come back later.
  • Never retry a send once it may have been processed. A read timeout, a dropped connection, a 500 or a 504 can all arrive after the message was queued.

urllib3's Retry, mounted on a requests.Session, expresses exactly that policy:

Pythonsms_client.py
# 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 session

We ran this configuration against a local test server before publishing. With urllib3 2.0:

  • A server that always answers 503 received four requests, the original plus three retries, spread over 3 to 4 seconds.
  • A 429 carrying Retry-After: 1 was retried once, after one second, and then succeeded.
  • A 504 on a send was not retried. The client raised SmsUncertainError after a single request.
  • A server that held the response past the read timeout received the request exactly once.

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.

5. A typed SmsClient

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.

Pythonsms_client.py (continued)
# 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.

6. DLT templates in Python

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:

  • Variables are tagged by type. TRAI's direction of 18 November 2025 requires every variable to be tagged at registration as a number, text, URL, app link, call-back number or email. Existing templates got 60 days to be updated. After that, messages that fail tag validation are rejected. Tag names differ by operator portal. Airtel uses {#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.
  • Each variable has a length limit. Tagged variables generally allow 40 characters, and URL tags allow 120. The legacy {#var#} allows 30. Check your operator's portal, because the sources do not all agree. A number tag must contain digits only.
  • Links and call-back numbers must be whitelisted. Any URL, APK or OTT link, or call-back number in a message has to be whitelisted on DLT. Traffic with non-whitelisted URLs has been blocked since 1 October 2024.
  • Everything else must match exactly. That includes spacing, punctuation and the brand name.

Keep templates as constants and fill them with one helper. Never build the sentence with f-strings scattered across views:

Pythontemplates.py
# 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. -AVNZON

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

7. Tamil, Unicode and segment counting

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.

EncodingSingle SMSPer part when splitTriggered by
GSM-7160 chars153 charsEnglish letters, digits, common punctuation (^{}[]~|\€ count as 2)
UCS-270 chars67 charsAny Tamil/Hindi letter, emoji, ₹, curly quotes
Pythonsms_parts.py
# 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 letters

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

8. Bulk sends

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:

Pythonbulk.py
# 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 failed

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

9. Check delivery status

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:

Pythonstatus.py
# 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.

10. Django settings and a Celery task

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:

Pythonsettings.py + sms/service.py
# 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 process

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

Pythonsms/tasks.py
# 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_id

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

11. OTP SMS with secrets, HMAC and rate limits

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:

Pythonsms/otp.py
# 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 True

Why these choices:

  • An HMAC with a pepper, not a plain hash. There are only a million 6-digit codes, so a plain SHA-256 of one is reversed instantly by anyone who can read your cache. With a server-side pepper, a leaked cache is useless on its own.
  • hmac.compare_digest compares in constant time, so response timing reveals nothing about how close a guess was.
  • Separate counters for requests, IPs and guesses. Five guesses per code, against a million possible codes, makes brute force impractical. The per-IP cap slows down SMS pumping through your form.

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.

12. Troubleshooting Python SMS integrations

SymptomLikely causeFix
KeyError: 'SMS_API_KEY'Variable set in your shell but not for gunicorn, uWSGI, systemd or the Celery workerSet it in the service unit or .env loader that starts the process
401 Invalid API keyWrong or revoked key, or a trailing newline copied into .env.strip() the value; regenerate the key if it leaked
400 Invalid sender IDHeader not approved on your account, or wrong caseUse the exact header listed by GET /api/v2/sender-ids
TEMPLATE_MISMATCHText differs from the approved DLT template, or a variable failed its tag checkFill with fill_template(); see our DLT error codes guide
422 on recipientSpaces, dashes or a leading 0 in the numberKeep digits only and send 91 + 10 digits
SSLError: CERTIFICATE_VERIFY_FAILEDOutdated certifi or system CA bundlepip install -U certifi; never pass verify=False
SmsUncertainError / ReadTimeoutRequest sent, but no clear answer: a timeout, a 500 or a 504Do 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 twiceA retry loop around post(), or Celery acks_late=TrueUse the session and task settings above
Accepted, never deliveredPromotional route to a DND number, or sent outside 9 AM–9 PMTransactional 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.

FAQ

Sending SMS from Python: FAQ

Can I send SMS from Python for free?
Not to Indian mobile numbers for anything a business sends. Every commercial SMS in India has to go through a DLT-registered gateway with an approved sender ID and template. The old PyPI packages that scraped free SMS websites stopped working years ago. For an application, use an SMS gateway API and pay per message.
Why does requests not retry my POST request?
urllib3's Retry leaves POST out of allowed_methods by default, because POST is not idempotent. You can add it, but then restrict retries to connection failures and to 429, 502 and 503 responses, and set read=False so a request that has already been sent is never sent again.
Should I use requests or httpx?
Either works. Use requests in Django views, scripts and Celery tasks. Use httpx when your code is already async (FastAPI, aiohttp workers), and cap concurrency with an asyncio.Semaphore. The same rule applies to both: only retry when the request provably never reached the gateway, or when it answered 429, 502 or 503. Never resend a send after a 500 or 504.
How do I send OTP SMS from Python securely?
Generate the code with secrets.randbelow(), never the random module. Store only an HMAC of it with a 10-minute expiry, rate-limit requests per number and per IP, cap verification attempts at about five, and delete the code once used. Send it on the transactional route with an approved OTP template. See our OTP SMS service for priority routing.
How many characters fit in one SMS?
A plain English (GSM-7) SMS holds 160 characters, or 153 per part when split. A Unicode SMS, which any Tamil or Hindi letter, emoji or the ₹ sign triggers, holds 70 characters, or 67 per part. Note that Python's len() counts code points while the network counts UTF-16 units, so an emoji is one character to len() but two in an SMS.
Is there a Python SDK?
The API is one authenticated HTTPS call with JSON in and JSON out, so the client class on this page is all most projects need. It covers sending, bulk, status and balance. The same API is shown in PHP, Node.js and Java.
READY TO INTEGRATE?

Get an API Key and Send Your First SMS from Python

Open an account and we help with DLT registration and template approval, so your code has an approved header and template from day one.