{"id":2617,"date":"2026-08-10T13:47:52","date_gmt":"2026-08-10T08:17:52","guid":{"rendered":"https:\/\/www.smsgatewaycenter.com\/blog\/?p=2617"},"modified":"2026-08-10T13:47:55","modified_gmt":"2026-08-10T08:17:55","slug":"sms-api-python-integration-tutorial","status":"publish","type":"post","link":"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-python-integration-tutorial\/","title":{"rendered":"SMS API in Python: Full Integration Tutorial"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Every Python SMS tutorial stops at a requests.post snippet. This one covers what happens after: why requests has no default timeout, why json= silently breaks a form-encoded API, pydantic parsing at the boundary, DLT template rendering that does not corrupt, Celery retry policy that does not double-bill, a FastAPI delivery receipt receiver, and tests.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Table of Contents<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li><a href=\"#the-short-answer\">The Short Answer<\/a><\/li>\n\n\n\n<li><a href=\"#tldr\">TL;DR<\/a><\/li>\n\n\n\n<li><a href=\"#what-every-other-python-sms-tutorial-leaves-out\">What Every Other Python SMS Tutorial Leaves Out<\/a><\/li>\n\n\n\n<li><a href=\"#prerequisites\">Prerequisites<\/a><\/li>\n\n\n\n<li><a href=\"#your-first-send-in-four-steps\">Your First Send in Four Steps<\/a><\/li>\n\n\n\n<li><a href=\"#the-four-layers\">The Four Layers<\/a><\/li>\n\n\n\n<li><a href=\"#layer-one-configuration-and-credentials\">Layer One: Configuration and Credentials<\/a><\/li>\n\n\n\n<li><a href=\"#layer-two-the-http-client\">Layer Two: The HTTP Client<\/a><\/li>\n\n\n\n<li><a href=\"#the-two-mistakes-that-break-python-sms-clients\">The Two Mistakes That Break Python SMS Clients<\/a><\/li>\n\n\n\n<li><a href=\"#layer-three-typed-responses\">Layer Three: Typed Responses<\/a><\/li>\n\n\n\n<li><a href=\"#counting-segments-before-you-send\">Counting Segments Before You Send<\/a><\/li>\n\n\n\n<li><a href=\"#the-india-dlt-layer\">The India DLT Layer<\/a><\/li>\n\n\n\n<li><a href=\"#rendering-dlt-variables-without-corrupting-them\">Rendering DLT Variables Without Corrupting Them<\/a><\/li>\n\n\n\n<li><a href=\"#sending-otps\">Sending OTPs<\/a><\/li>\n\n\n\n<li><a href=\"#classifying-failures-before-you-retry\">Classifying Failures Before You Retry<\/a><\/li>\n\n\n\n<li><a href=\"#concurrency-threads-asyncio-and-why-gather-is-wrong-twice\">Concurrency: Threads, Asyncio and Why gather Is Wrong Twice<\/a><\/li>\n\n\n\n<li><a href=\"#layer-four-celery-for-production-sending\">Layer Four: Celery for Production Sending<\/a><\/li>\n\n\n\n<li><a href=\"#receiving-delivery-reports-with-fastapi\" data-type=\"internal\" data-id=\"#receiving-delivery-reports-with-fastapi\">Receiving Delivery Reports with FastAPI<\/a><\/li>\n\n\n\n<li><a href=\"#polling-delivery-reports-instead\">Polling Delivery Reports Instead<\/a><\/li>\n\n\n\n<li><a href=\"#the-official-python-sdk\">The Official Python SDK<\/a><\/li>\n\n\n\n<li><a href=\"#security-and-secrets\">Security and Secrets<\/a><\/li>\n\n\n\n<li><a href=\"#testing\">Testing<\/a><\/li>\n\n\n\n<li><a href=\"#production-readiness-checklist\">Production Readiness Checklist<\/a><\/li>\n\n\n\n<li><a href=\"#ten-mistakes\">Ten Mistakes<\/a><\/li>\n\n\n\n<li><a href=\"#frequently-asked-questions\">Frequently Asked Questions<\/a><\/li>\n<\/ol>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"the-short-answer\" class=\"wp-block-heading\">The Short Answer<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">To send an SMS from Python, POST form-encoded credentials and message parameters to <code>https:\/\/unify.smsgateway.center\/SMSApi\/send<\/code> with an explicit timeout, then parse the JSON response and check <code>statusCode<\/code> rather than the HTTP status. That is five lines. Everything that makes the difference between a snippet and a system happens around those five lines: <code>requests<\/code> applies no timeout unless you pass one, so a stalled connection hangs your worker forever; the API is form-encoded, so passing <code>json=<\/code> silently sends the wrong content type; a timeout does not tell you whether the message was accepted, so retrying without an idempotency key double-sends and double-bills; and India&#8217;s DLT rules require template registration and careful variable substitution that naive <code>str.replace<\/code> chaining will corrupt.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/sms-api-python-integration-tutorial.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"584\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/sms-api-python-integration-tutorial-1024x584.webp\" alt=\"Diagram showing a single SMS of 160 characters splitting into three 153-character billed segments alongside a depleting credit meter, illustrating bulk SMS cost mechanics in India.\" class=\"wp-image-2618\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/sms-api-python-integration-tutorial-1024x584.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/sms-api-python-integration-tutorial-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/sms-api-python-integration-tutorial-768x438.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/sms-api-python-integration-tutorial.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"tldr\">TL;DR<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong><code>requests<\/code> has no default timeout.<\/strong> <code>requests.post(url, data=payload)<\/code> can hang indefinitely. Always pass <code>timeout=(connect, read)<\/code>. <code>httpx<\/code> defaults to five seconds, which is better, but teams routinely disable it.<\/li>\n\n\n\n<li><strong>Use <code>data=<\/code>, never <code>json=<\/code>.<\/strong> The endpoint expects <code>application\/x-www-form-urlencoded<\/code>. Passing <code>json=<\/code> sets <code>Content-Type: application\/json<\/code> and the request fails in a way that looks like an auth problem.<\/li>\n\n\n\n<li><strong>Check <code>statusCode<\/code> in the body, not the HTTP status.<\/strong> A successful send returns <code>\"statusCode\": \"200\"<\/code> as a <strong>string<\/strong> on <code>SMSApi\/send<\/code>, and an <strong>integer<\/strong> on <code>WAApi\/send<\/code>. Type your model to accept both.<\/li>\n\n\n\n<li><strong>Reuse a <code>Session<\/code> or <code>httpx.Client<\/code>.<\/strong> A fresh connection per message means a fresh TLS handshake per message.<\/li>\n\n\n\n<li><strong>Count segments before sending.<\/strong> One credit is 160 GSM-7 characters or 70 Unicode. A single curly apostrophe re-encodes the whole message and can triple its cost.<\/li>\n\n\n\n<li><strong>Substitute DLT variables positionally in one pass.<\/strong> <code>re.sub<\/code> interprets backreferences in the replacement string, and chained <code>str.replace<\/code> corrupts when a substituted value contains the next placeholder.<\/li>\n\n\n\n<li><strong>Classify failures before retrying.<\/strong> Permanent failures never succeed and always bill. Only transient ones deserve backoff.<\/li>\n\n\n\n<li><strong>Celery with <code>acks_late<\/code> plus a non-idempotent send equals double delivery on worker crash.<\/strong> Pair it with an idempotency key.<\/li>\n\n\n\n<li><strong>Read the raw body in your DLR webhook.<\/strong> A form-encoded payload fed to a JSON parser or a pydantic form model fails or silently loses fields.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"what-every-other-python-sms-tutorial-leaves-out\">What Every Other Python SMS Tutorial Leaves Out<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The pages currently ranking for this query, from vendors like Vonage, Telesign, LabsMobile, SMSAPI, smsmode, Ozeki and Melrose Labs, rank because they answer the literal question fast: here is <code>import requests<\/code>, here is <code>requests.post<\/code>, here is your message. That is legitimately what a developer wants in the first ninety seconds, and it is why those pages win the click.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">They stop there. Not one of them addresses:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Gap<\/th><th>Why it matters in Python specifically<\/th><\/tr><\/thead><tbody><tr><td>No default timeout in <code>requests<\/code><\/td><td>A hung socket blocks a Gunicorn worker or a Celery process forever. This is the single most common Python integration outage.<\/td><\/tr><tr><td><code>json=<\/code> versus <code>data=<\/code><\/td><td>Python&#8217;s two keyword arguments look interchangeable and are not. The failure presents as a confusing auth error.<\/td><\/tr><tr><td>Response type instability<\/td><td><code>statusCode<\/code> is a quoted string on one endpoint and an integer on another. Untyped <code>dict<\/code> access papers over it until it does not.<\/td><\/tr><tr><td><code>urllib3<\/code> <code>Retry<\/code> on POST<\/td><td>Enabling automatic retries on a non-idempotent POST double-sends and double-bills.<\/td><\/tr><tr><td><code>asyncio.gather<\/code> semantics<\/td><td>Unbounded fan-out plus first-exception cancellation that discards successful results.<\/td><\/tr><tr><td>Celery <code>acks_late<\/code><\/td><td>Redelivers on worker loss, which is correct for durability and catastrophic without idempotency.<\/td><\/tr><tr><td>Form-encoded webhooks in FastAPI<\/td><td>The idiomatic pydantic body model does not apply to a form-encoded push.<\/td><\/tr><tr><td>GSM-7 versus UCS-2<\/td><td>Affects cost by up to 3x and is invisible in code review.<\/td><\/tr><tr><td>India DLT<\/td><td>Mandatory for Indian A2P traffic and absent from every international tutorial.<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">This article is the part after the snippet. If you want the snippet first, the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-getting-started-guide\/\">SMS API getting started guide<\/a> covers authentication and a minimal call across languages. The sibling deep dives are <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-php-integration-tutorial\/\">SMS API in PHP<\/a> and <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-nodejs-integration-tutorial\/\">SMS API in Node.js<\/a>, which cover the same architecture with each runtime&#8217;s own failure surface.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"prerequisites\">Prerequisites<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Python 3.11 or newer.<\/strong> The code uses <code>X | Y<\/code> union syntax, <code>StrEnum<\/code>, and <code>tomllib<\/code>-era typing conventions. Everything degrades gracefully to 3.10 with <code>typing.Union<\/code>.<\/li>\n\n\n\n<li><strong>An SMSGatewayCenter account<\/strong> with either a username and password or an API key from your control panel.<\/li>\n\n\n\n<li><strong>A registered sender ID.<\/strong> Indian headers are alphanumeric, up to six characters.<\/li>\n\n\n\n<li><strong>DLT registration<\/strong> if you are sending to Indian numbers. This is not optional; see the <a href=\"https:\/\/www.smsgatewaycenter.com\/dlt-sms\/\">DLT SMS hub<\/a>.<\/li>\n\n\n\n<li><strong>TLS 1.3 support.<\/strong> Older Python builds linked against ancient OpenSSL will fail the handshake. If you hit an SSL error before you hit an API error, work through the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/tls13-upgrade-guide\/\">TLS 1.3 upgrade guide<\/a> before debugging anything else.<\/li>\n<\/ul>\n\n\n\n<pre class=\"wp-block-code\"><code>python -m venv .venv\nsource .venv\/bin\/activate\npip install httpx pydantic tenacity celery redis fastapi uvicorn python-dotenv\npip install --group dev pytest pytest-asyncio respx\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If you prefer <code>requests<\/code>, everything below translates directly and the differences are called out where they matter.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"your-first-send-in-four-steps\">Your First Send in Four Steps<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 1: Store your credentials outside your code.<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># .env  (add to .gitignore)\nSGC_USERID=your_username\nSGC_PASSWORD=your_password\nSGC_SENDERID=YOURID\nSGC_DLT_ENTITY_ID=1234567890123456789\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 2: Send one message.<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import os\n\nimport httpx\nfrom dotenv import load_dotenv\n\nload_dotenv()\n\nresponse = httpx.post(\n    \"https:\/\/unify.smsgateway.center\/SMSApi\/send\",\n    data={\n        \"userid\": os.environ&#91;\"SGC_USERID\"],\n        \"password\": os.environ&#91;\"SGC_PASSWORD\"],\n        \"sendMethod\": \"quick\",\n        \"mobile\": \"919000000000\",\n        \"msg\": \"Your order 4821 has shipped.\",\n        \"senderid\": os.environ&#91;\"SGC_SENDERID\"],\n        \"msgType\": \"text\",\n        \"duplicatecheck\": \"true\",\n        \"output\": \"json\",\n    },\n    timeout=httpx.Timeout(15.0, connect=5.0),\n)\n\nprint(response.json())\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 3: Read the response.<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"status\": \"success\",\n  \"mobile\": \"919000000000\",\n  \"invalidMobile\": \"\",\n  \"transactionId\": \"a1b2c3d4-...\",\n  \"statusCode\": \"200\",\n  \"reason\": \"success\"\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 4: Check the right field.<\/strong> <code>response.status_code<\/code> is HTTP. <code>response.json()[\"statusCode\"]<\/code> is the API verdict. They disagree more often than you would expect, which is why the next several sections exist.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The equivalent cURL<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Useful for reproducing a bug outside your application, and for pasting into a ticket:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>curl --request POST \\\n  --url https:\/\/unify.smsgateway.center\/SMSApi\/send \\\n  --header 'content-type: application\/x-www-form-urlencoded' \\\n  --data-urlencode 'userid=YourUsername' \\\n  --data-urlencode 'password=YourPassword' \\\n  --data-urlencode 'sendMethod=quick' \\\n  --data-urlencode 'mobile=919000000000' \\\n  --data-urlencode 'msg=Your order 4821 has shipped.' \\\n  --data-urlencode 'senderid=YOURID' \\\n  --data-urlencode 'msgType=text' \\\n  --data-urlencode 'output=json'\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The full parameter reference lives on the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/send-sms-batch\/\">Send SMS API documentation<\/a>, and there is a ready-made <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/download-sms-postman\/\">Postman collection<\/a> if you would rather click than type.<\/p>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\">Want to run these calls before writing any code? Download the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/download-sms-postman\/\">SMS Postman collection<\/a> and send your first message in under a minute.<\/p>\n<\/blockquote>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"the-four-layers\">The Four Layers<\/h2>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/python-four-layers.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"584\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/python-four-layers-1024x584.webp\" alt=\"Diagram explaining Four Layers\" class=\"wp-image-2620\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/python-four-layers-1024x584.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/python-four-layers-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/python-four-layers-768x438.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/python-four-layers.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">A production integration separates into four layers. Keeping them separate is what lets you test the middle two without a network and swap the outer two without rewriting logic.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Layer<\/th><th>Responsibility<\/th><th>Depends on<\/th><th>Test strategy<\/th><\/tr><\/thead><tbody><tr><td><strong>1. Configuration<\/strong><\/td><td>Credentials, sender IDs, DLT identifiers, base URL<\/td><td>Environment only<\/td><td>Trivial, construct and assert<\/td><\/tr><tr><td><strong>2. Transport<\/strong><\/td><td>Connection pooling, timeouts, form encoding, raw response<\/td><td>Layer 1<\/td><td>Mock the network with <code>respx<\/code><\/td><\/tr><tr><td><strong>3. Domain<\/strong><\/td><td>Segment counting, template rendering, response typing, failure classification<\/td><td>Nothing external<\/td><td>Pure unit tests, no mocks at all<\/td><\/tr><tr><td><strong>4. Orchestration<\/strong><\/td><td>Queueing, retries, concurrency, scheduling<\/td><td>Layers 2 and 3<\/td><td>Integration tests with a Celery eager mode<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Layer 3 is the valuable one and the one tutorials never build. It has no I\/O, so it is fast and exhaustively testable, and it is where every expensive bug actually lives.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"layer-one-configuration-and-credentials\">Layer One: Configuration and Credentials<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>from __future__ import annotations\n\nimport os\nfrom dataclasses import dataclass, field\n\n\n@dataclass(frozen=True, slots=True)\nclass SmsConfig:\n    userid: str\n    password: str = field(repr=False)\n    senderid: str\n    base_url: str = \"https:\/\/unify.smsgateway.center\"\n    api_key: str | None = field(default=None, repr=False)\n    dlt_entity_id: str | None = None\n    connect_timeout: float = 5.0\n    read_timeout: float = 15.0\n\n    @classmethod\n    def from_env(cls) -&gt; SmsConfig:\n        try:\n            return cls(\n                userid=os.environ&#91;\"SGC_USERID\"],\n                password=os.environ&#91;\"SGC_PASSWORD\"],\n                senderid=os.environ&#91;\"SGC_SENDERID\"],\n                api_key=os.environ.get(\"SGC_API_KEY\"),\n                dlt_entity_id=os.environ.get(\"SGC_DLT_ENTITY_ID\"),\n            )\n        except KeyError as exc:\n            raise RuntimeError(\n                f\"Missing required environment variable: {exc.args&#91;0]}\"\n            ) from None\n\n    def auth_payload(self) -&gt; dict&#91;str, str]:\n        \"\"\"Credentials go in the body only when no API key is configured.\"\"\"\n        return {} if self.api_key else {\n            \"userid\": self.userid,\n            \"password\": self.password,\n        }\n\n    def auth_headers(self) -&gt; dict&#91;str, str]:\n        return {\"apiKey\": self.api_key} if self.api_key else {}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two details worth copying.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>field(repr=False)<\/code> on secrets.<\/strong> A frozen dataclass generates a <code>__repr__<\/code> that includes every field. The moment that object appears in a traceback, a Sentry event or a debug log, your password is in it. Marking secret fields <code>repr=False<\/code> removes them from the generated representation. This is the Python equivalent of the <code>__debugInfo<\/code> override used in the PHP tutorial and it costs one keyword argument.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>from None<\/code> on the re-raise.<\/strong> Without it, the <code>KeyError<\/code> chains into the traceback, and the chained exception&#8217;s message is the missing variable name. That is harmless here, but the habit matters: exception chaining leaks context, and credential-adjacent code is exactly where you do not want extra context.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">API key versus username and password<\/h3>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th><\/th><th><code>userid<\/code> + <code>password<\/code><\/th><th><code>apiKey<\/code> header<\/th><\/tr><\/thead><tbody><tr><td>Sent in<\/td><td>Request body<\/td><td>HTTP header<\/td><\/tr><tr><td>Appears in server access logs<\/td><td>Only if the body is logged, which it should not be<\/td><td>Only if headers are logged, which some proxies do<\/td><\/tr><tr><td>Rotatable without a password change<\/td><td>No<\/td><td>Yes<\/td><\/tr><tr><td>Revocable independently<\/td><td>No<\/td><td>Yes<\/td><\/tr><tr><td>Distinct key per service<\/td><td>No<\/td><td>Yes<\/td><\/tr><tr><td>Recommended for server-to-server<\/td><td>Acceptable<\/td><td><strong>Preferred<\/strong><\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Use the API key in anything running unattended. Reserve the password path for interactive tooling and first-run testing. Either way, the password must be URL-encoded if it contains special characters, and <code>httpx<\/code> and <code>requests<\/code> both handle that for you when you pass <code>data=<\/code> a dict.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"layer-two-the-http-client\">Layer Two: The HTTP Client<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>from __future__ import annotations\n\nimport httpx\n\n\nclass SmsTransport:\n    \"\"\"Owns the connection pool. Construct once, reuse for the process lifetime.\"\"\"\n\n    def __init__(self, config: SmsConfig) -&gt; None:\n        self._config = config\n        self._client = httpx.Client(\n            base_url=config.base_url,\n            timeout=httpx.Timeout(\n                config.read_timeout,\n                connect=config.connect_timeout,\n            ),\n            limits=httpx.Limits(\n                max_connections=50,\n                max_keepalive_connections=20,\n                keepalive_expiry=30.0,\n            ),\n            headers={\n                \"content-type\": \"application\/x-www-form-urlencoded\",\n                \"user-agent\": \"acme-notifications\/1.4 (+python-httpx)\",\n                **config.auth_headers(),\n            },\n            # Do NOT enable automatic retries here. See below.\n            follow_redirects=False,\n        )\n\n    def post_form(self, path: str, payload: dict&#91;str, str]) -&gt; httpx.Response:\n        body = {**self._config.auth_payload(), **payload, \"output\": \"json\"}\n        return self._client.post(path, data=body)\n\n    def close(self) -&gt; None:\n        self._client.close()\n\n    def __enter__(self) -&gt; SmsTransport:\n        return self\n\n    def __exit__(self, *exc_info: object) -&gt; None:\n        self.close()\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Points that are load-bearing:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>data=<\/code>, not <code>json=<\/code>.<\/strong> This is the single most common integration failure in Python against form-encoded APIs. <code>httpx.post(url, json=payload)<\/code> serialises to JSON and sets <code>Content-Type: application\/json<\/code>. The endpoint expects <code>application\/x-www-form-urlencoded<\/code>, so it does not see your <code>userid<\/code> at all, and returns an authentication error. Developers then spend an hour re-checking credentials that were never wrong. The <code>output=json<\/code> parameter controls the <strong>response<\/strong> format and has nothing to do with the request encoding.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>One client, not one per call.<\/strong> <code>httpx.post(...)<\/code> at module level creates and discards a client, and therefore a TLS handshake, on every message. Under load that dominates your latency. Construct <code>SmsTransport<\/code> once at application startup.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>follow_redirects=False<\/code>.<\/strong> <code>httpx<\/code> defaults to not following redirects, unlike <code>requests<\/code>, which does. A redirect on a POST is not something you want silently followed with credentials attached. Being explicit documents the intent.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>No <code>raise_for_status()<\/code> in the transport.<\/strong> When the API returns a non-2xx with a JSON error body, raising immediately discards the body, which is where <code>reason<\/code> and <code>statusCode<\/code> live. Let layer 3 decide.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">If you are using <code>requests<\/code> instead<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>import requests\nfrom requests.adapters import HTTPAdapter\n\nsession = requests.Session()\nsession.headers.update({\n    \"content-type\": \"application\/x-www-form-urlencoded\",\n})\nsession.mount(\n    \"https:\/\/\",\n    HTTPAdapter(pool_connections=10, pool_maxsize=50, max_retries=0),\n)\n\nresponse = session.post(\n    \"https:\/\/unify.smsgateway.center\/SMSApi\/send\",\n    data=payload,\n    timeout=(5, 15),          # (connect, read) - NEVER omit this\n)\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Note <code>max_retries=0<\/code>, explicitly. See the next section for why that is not paranoia.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"the-two-mistakes-that-break-python-sms-clients\">The Two Mistakes That Break Python SMS Clients<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Mistake one: omitting the timeout<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This is worth its own section because it is the most damaging and the least visible.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># This can hang forever. There is no default timeout in requests.\nrequests.post(url, data=payload)\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>requests<\/code> documentation is explicit that without a <code>timeout<\/code>, a request can hang indefinitely. Not for thirty seconds. Indefinitely. If the remote end accepts your connection and then stops responding, the socket read blocks with no deadline.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In a synchronous web application, that ties up a worker process. Send a few hundred messages while the upstream is degraded and every worker is blocked on a socket, your health check fails, and your orchestrator restarts a service whose actual problem is one missing keyword argument.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>httpx<\/code> is better here: it applies a five-second default. The trap is different in <code>httpx<\/code>, and it is that <code>timeout=None<\/code> disables timeouts entirely, and developers reach for it the first time a legitimate slow response gets cut off. The correct fix is to raise the read timeout, not remove it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Use a granular timeout so the two phases are tuned independently:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>httpx.Timeout(\n    15.0,        # default for read, write and pool\n    connect=5.0, # a TCP handshake that takes 5s is never going to recover\n)\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Connect timeouts should be short. Read timeouts should exceed the API&#8217;s worst realistic processing time.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Mistake two: automatic retries on POST<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Both <code>urllib3<\/code> and <code>httpx<\/code> transports can be configured to retry failed requests transparently. On a POST that sends money or messages, this is a bug.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># DANGEROUS on a send endpoint.\nfrom urllib3.util.retry import Retry\n\nretry = Retry(total=3, allowed_methods=&#91;\"POST\"], status_forcelist=&#91;502, 503, 504])\nsession.mount(\"https:\/\/\", HTTPAdapter(max_retries=retry))\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The failure sequence: the platform accepts your message, begins processing, and the response is lost to a network blip. <code>urllib3<\/code> sees no response, retries, and the platform accepts the message a second time. Your recipient gets two messages, and since <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/bulk-sms-pricing-india-what-drives-cost\/\">credits are consumed at submission rather than delivery<\/a>, you paid twice.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Transport-level retries are safe on idempotent GETs, such as delivery report polling. They are unsafe on sends. Keep <code>max_retries=0<\/code> on the send path and do retries in layer 4, where you control the idempotency key.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"layer-three-typed-responses\">Layer Three: Typed Responses<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>from __future__ import annotations\n\nfrom enum import StrEnum\n\nfrom pydantic import BaseModel, Field, field_validator\n\n\nclass SendStatus(StrEnum):\n    SUCCESS = \"success\"\n    ERROR = \"error\"\n\n\nclass SendResult(BaseModel):\n    status: SendStatus\n    status_code: str = Field(alias=\"statusCode\")\n    reason: str = \"\"\n    mobile: str = \"\"\n    invalid_mobile: str = Field(default=\"\", alias=\"invalidMobile\")\n    transaction_id: str | None = Field(default=None, alias=\"transactionId\")\n\n    model_config = {\"populate_by_name\": True, \"extra\": \"ignore\"}\n\n    @field_validator(\"status_code\", mode=\"before\")\n    @classmethod\n    def _coerce_status_code(cls, value: object) -&gt; str:\n        \"\"\"SMSApi\/send returns \"200\" quoted. WAApi\/send returns 200 unquoted.\"\"\"\n        return str(value)\n\n    @field_validator(\"transaction_id\", mode=\"before\")\n    @classmethod\n    def _coerce_transaction_id(cls, value: object) -&gt; str | None:\n        return None if value is None else str(value)\n\n    @property\n    def ok(self) -&gt; bool:\n        return self.status is SendStatus.SUCCESS and self.status_code == \"200\"\n\n    @property\n    def rejected_numbers(self) -&gt; list&#91;str]:\n        return &#91;n for n in self.invalid_mobile.split(\",\") if n.strip()]\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The two validators are not defensive programming for its own sake. They encode a real, observed inconsistency: <code>statusCode<\/code> comes back as the string <code>\"200\"<\/code> from <code>SMSApi\/send<\/code> and as the integer <code>200<\/code> from <code>WAApi\/send<\/code>. If you write <code>if data[\"statusCode\"] == 200:<\/code> it works on one endpoint and silently fails on the other, and the failure is a false negative, meaning you treat a successful WhatsApp send as failed and retry it. Normalising to <code>str<\/code> at the boundary makes the rest of your code single-typed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>extra: \"ignore\"<\/code> matters too. If the platform adds a field, your parser should not start throwing.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Composing the send<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>class SmsClient:\n    def __init__(self, config: SmsConfig, transport: SmsTransport) -&gt; None:\n        self._config = config\n        self._transport = transport\n\n    def send(\n        self,\n        *,\n        mobile: str,\n        message: str,\n        sender_id: str | None = None,\n        unicode: bool = False,\n        dlt_template_id: str | None = None,\n        duplicate_check: bool = True,\n    ) -&gt; SendResult:\n        payload: dict&#91;str, str] = {\n            \"sendMethod\": \"quick\",\n            \"mobile\": mobile,\n            \"msg\": message,\n            \"senderid\": sender_id or self._config.senderid,\n            \"msgType\": \"unicode\" if unicode else \"text\",\n            \"duplicatecheck\": \"true\" if duplicate_check else \"false\",\n        }\n\n        if dlt_template_id:\n            payload&#91;\"dltTemplateId\"] = dlt_template_id\n        if self._config.dlt_entity_id:\n            payload&#91;\"dltEntityId\"] = self._config.dlt_entity_id\n\n        response = self._transport.post_form(\"\/SMSApi\/send\", payload)\n        return SendResult.model_validate(response.json())\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Keyword-only arguments (the bare <code>*<\/code>) are deliberate. <code>send(\"919000000000\", \"text\")<\/code> versus <code>send(\"text\", \"919000000000\")<\/code> is a positional mistake that type checkers cannot catch when both parameters are <code>str<\/code>. Forcing keywords eliminates the whole class of error.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"counting-segments-before-you-send\">Counting Segments Before You Send<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">One credit covers 160 GSM-7 characters or 70 Unicode characters. Concatenated messages drop to 153 and 67 per segment because the User Data Header consumes payload. A single character outside the GSM-7 alphabet re-encodes the entire message.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>from math import ceil\nfrom typing import NamedTuple\n\nGSM_BASIC = set(\n    \"@\u00a3$\u00a5\u00e8\u00e9\u00f9\u00ec\u00f2\u00c7\\n\u00d8\u00f8\\r\u00c5\u00e5\u0394_\u03a6\u0393\u039b\u03a9\u03a0\u03a8\u03a3\u0398\u039e\u00c6\u00e6\u00df\u00c9 !\\\"#\u00a4%&amp;'()*+,-.\/0123456789:;&lt;=&gt;?\"\n    \"\u00a1ABCDEFGHIJKLMNOPQRSTUVWXYZ\u00c4\u00d6\u00d1\u00dc\u00a7\u00bfabcdefghijklmnopqrstuvwxyz\u00e4\u00f6\u00f1\u00fc\u00e0\"\n)\n# Extension-table characters cost TWO units each (escape + character).\nGSM_EXTENDED = set(\"^{}\\\\&#91;~]|\u20ac\")\n\n\nclass Segmentation(NamedTuple):\n    encoding: str\n    units: int\n    segments: int\n    per_segment: int\n\n    @property\n    def is_unicode(self) -&gt; bool:\n        return self.encoding == \"UNICODE\"\n\n\ndef segment(message: str) -&gt; Segmentation:\n    units = 0\n    is_gsm = True\n\n    for char in message:\n        if char in GSM_BASIC:\n            units += 1\n        elif char in GSM_EXTENDED:\n            units += 2\n        else:\n            is_gsm = False\n            break\n\n    if not is_gsm:\n        # UCS-2 bills 16-bit units, so an astral-plane emoji counts as two.\n        units = len(message.encode(\"utf-16-be\")) \/\/ 2\n\n    single, multi = (160, 153) if is_gsm else (70, 67)\n    segments = 1 if units &lt;= single else ceil(units \/ multi)\n\n    return Segmentation(\n        encoding=\"TEXT\" if is_gsm else \"UNICODE\",\n        units=units,\n        segments=max(1, segments),\n        per_segment=single if units &lt;= single else multi,\n    )\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two subtleties that most implementations get wrong:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Iterating a <code>str<\/code> yields code points, not UTF-16 units.<\/strong> That is correct for the GSM branch. For the Unicode branch we deliberately switch to <code>len(message.encode(\"utf-16-be\")) \/\/ 2<\/code>, because UCS-2 billing counts 16-bit units and a non-BMP emoji occupies two of them. Using <code>len(message)<\/code> in the Unicode branch undercounts emoji.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The extension table is not decoration.<\/strong> <code>{<\/code>, <code>}<\/code>, <code>[<\/code>, <code>]<\/code>, <code>~<\/code>, <code>^<\/code>, <code>\\<\/code>, <code>|<\/code> and <code>\u20ac<\/code> each consume two units. A message that <code>len()<\/code> reports as 158 characters can be 164 GSM-7 units, which is two segments. URLs containing brackets are the usual culprit.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Verifying against the platform<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">You do not have to trust your own counter. The platform exposes its own calculator:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def price(transport: SmsTransport, message: str) -&gt; dict&#91;str, object]:\n    response = transport.post_form(\"\/SMSApi\/info\/msg\", {\"msg\": message})\n    return response.json()&#91;\"response\"]&#91;\"msgList\"]\n\n\n# {'encoding': 'TEXT', 'length': 11, 'remaining': 149, 'credit': 1}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>POST https:\/\/unify.smsgateway.center\/SMSApi\/info\/msg<\/code> returns <code>encoding<\/code>, <code>length<\/code>, <code>remaining<\/code> and <code>credit<\/code>, documented on the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/get-sms-length-cost\/\">Get SMS Length and Cost<\/a> page.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Call this at template save time, not send time.<\/strong> Adding a network round trip to every outbound message buys nothing if your local counter is correct. Call it once when a template is created or edited, assert that a template you expect to be English comes back as <code>TEXT<\/code>, and block the save otherwise. In CI:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import pytest\n\n\n@pytest.mark.parametrize(\"name,template\", ENGLISH_TEMPLATES.items())\ndef test_english_templates_stay_gsm7(name: str, template: str) -&gt; None:\n    result = segment(template)\n    assert result.encoding == \"TEXT\", (\n        f\"Template {name!r} is Unicode. A curly quote, en dash, ellipsis \"\n        f\"or rupee sign has crept in and will multiply its cost.\"\n    )\n    assert result.segments == 1, (\n        f\"Template {name!r} needs {result.segments} segments \"\n        f\"({result.units} units). Trim it.\"\n    )\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That test has caught more real money than any performance optimisation I have written. The cost mechanics behind it are in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/bulk-sms-pricing-india-what-drives-cost\/\">bulk SMS pricing breakdown<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"the-india-dlt-layer\">The India DLT Layer<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">For Indian A2P traffic, TRAI&#8217;s regulations require every sender and every message template to be pre-registered on an operator DLT portal. Practically, that means two extra parameters on the send call and one extra validation step before it.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Parameter<\/th><th>Meaning<\/th><\/tr><\/thead><tbody><tr><td><code>dltEntityId<\/code><\/td><td>Your registered Principal Entity identifier. Constant per account.<\/td><\/tr><tr><td><code>dltTemplateId<\/code><\/td><td>The registered template identifier. Varies per message class.<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The message you send must match the approved template exactly, allowing for variable substitution. Mismatches are rejected before submission, which is at least free, but they are also invisible until you look.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Validate before you send<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>from pydantic import BaseModel, Field\n\n\nclass TemplateValidation(BaseModel):\n    status: str\n    status_code: str = Field(alias=\"statusCode\")\n    reason: str = \"\"\n    template: str = \"\"\n\n    model_config = {\"populate_by_name\": True, \"extra\": \"ignore\"}\n\n    @property\n    def matched(self) -&gt; bool:\n        return self.status == \"success\" and self.status_code == \"200\"\n\n    @property\n    def is_mismatch(self) -&gt; bool:\n        return self.status_code == \"188\"\n\n\ndef validate_template(\n    transport: SmsTransport,\n    *,\n    sender_id: str,\n    message: str,\n    dlt_template_id: str,\n) -&gt; TemplateValidation:\n    response = transport.post_form(\n        \"\/SMSApi\/validateTemplate\",\n        {\n            \"senderid\": sender_id,\n            \"msg\": message,\n            \"dltTemplateId\": dlt_template_id,\n        },\n    )\n    return TemplateValidation.model_validate(response.json())\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A mismatch returns <code>{\"status\": \"error\", \"statusCode\": \"188\", \"reason\": \"message template mismatch\"}<\/code>. The endpoint is documented at <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/validateTemplate\/\">validateTemplate<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Wire this into CI, not into your send path.<\/strong> Rendering every template with representative variables and asserting <code>matched<\/code> at build time catches a broken template before it reaches production. Calling it before every send doubles your API calls to prevent a problem that a build-time check already eliminated.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>@pytest.mark.integration\n@pytest.mark.parametrize(\"case\", TEMPLATE_FIXTURES)\ndef test_registered_templates_still_match(transport, case) -&gt; None:\n    rendered = render_dlt(case.template, case.sample_values)\n    result = validate_template(\n        transport,\n        sender_id=case.sender_id,\n        message=rendered,\n        dlt_template_id=case.dlt_template_id,\n    )\n    assert result.matched, f\"{case.name}: {result.reason}\"\n<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"rendering-dlt-variables-without-corrupting-them\">Rendering DLT Variables Without Corrupting Them<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">DLT templates use <code>{#var#}<\/code> as the placeholder, and every placeholder is identical. There is no name to match on, so substitution is <strong>positional<\/strong>. This is where two idiomatic Python approaches both produce silent corruption.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why <code>re.sub<\/code> is unsafe here<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code># BROKEN. Do not do this.\nimport re\n\ndef render_broken(template: str, values: list&#91;str]) -&gt; str:\n    for value in values:\n        template = re.sub(r\"\\{#var#\\}\", value, template, count=1)\n    return template\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>re.sub<\/code> treats the replacement string as a <strong>template<\/strong>, not a literal. Backslash sequences in it are interpreted. A value of <code>C:\\1 Depot<\/code> inserts the first capture group, not the text. A value containing <code>\\g&lt;0&gt;<\/code> inserts the whole match. Customer names, addresses and order references are user-controlled data, and user-controlled data reaching a regex replacement string is a corruption bug at best.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>re.escape<\/code> does not help, because it escapes the <strong>pattern<\/strong>, not the replacement. The correct escape is <code>value.replace(\"\\\\\", \"\\\\\\\\\")<\/code>, which is easy to forget and easier to get wrong.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why chained <code>str.replace<\/code> is unsafe here<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code># ALSO BROKEN.\ndef render_also_broken(template: str, values: list&#91;str]) -&gt; str:\n    for value in values:\n        template = template.replace(\"{#var#}\", value, 1)\n    return template\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This one is subtler. If a substituted value itself contains the literal text <code>{#var#}<\/code>, the next iteration will substitute <strong>into the value you just inserted<\/strong> rather than into the next real placeholder. Every subsequent variable lands in the wrong slot. It only happens when data contains the placeholder text, which sounds unlikely until a support agent pastes a template fragment into a customer name field.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The correct approach: one positional pass<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>PLACEHOLDER = \"{#var#}\"\n\n\nclass TemplateArityError(ValueError):\n    \"\"\"Raised when the value count does not match the placeholder count.\"\"\"\n\n\ndef render_dlt(template: str, values: list&#91;str]) -&gt; str:\n    \"\"\"Substitute {#var#} placeholders positionally in a single left-to-right pass.\n\n    Never re-scans inserted text, so a value containing the placeholder\n    literal cannot shift subsequent substitutions.\n    \"\"\"\n    expected = template.count(PLACEHOLDER)\n    if expected != len(values):\n        raise TemplateArityError(\n            f\"Template expects {expected} value(s), received {len(values)}.\"\n        )\n\n    out: list&#91;str] = &#91;]\n    cursor = 0\n    for value in values:\n        index = template.index(PLACEHOLDER, cursor)\n        out.append(template&#91;cursor:index])\n        out.append(str(value))\n        cursor = index + len(PLACEHOLDER)\n    out.append(template&#91;cursor:])\n\n    return \"\".join(out)\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The cursor only ever moves forward across the <strong>original<\/strong> template, and inserted values go into a separate output list that is never scanned. Both failure modes are structurally impossible.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The arity check earns its place too. A template that expects three variables and receives two would otherwise ship with a literal <code>{#var#}<\/code> visible to the customer, and it would pass template validation only if the platform&#8217;s normalisation happened to tolerate it. Failing loudly at render time is correct.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&gt;&gt;&gt; render_dlt(\"Hi {#var#}, order {#var#} ships on {#var#}.\", &#91;\"Asha\", \"4821\", \"Tuesday\"])\n'Hi Asha, order 4821 ships on Tuesday.'\n\n&gt;&gt;&gt; render_dlt(\"Hi {#var#}, ref {#var#}.\", &#91;\"{#var#}\", \"9\"])\n'Hi {#var#}, ref 9.'      # the injected placeholder stays inert\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This same hazard exists in every language. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-nodejs-integration-tutorial\/\">Node.js tutorial<\/a> documents the JavaScript variant, where <code>String.replace<\/code> expands <code>$&amp;<\/code> in the replacement.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"sending-otps\">Sending OTPs<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">There is a dedicated endpoint for one-time passwords that generates, stores and verifies the code, so you never persist it yourself.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Generate:<\/strong> <code>POST https:\/\/unify.smsgateway.center\/SMSApi\/otp<\/code> with <code>sendMethod=generate<\/code>.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>from pydantic import BaseModel, Field\n\n\nclass OtpGenerated(BaseModel):\n    status: str\n    status_code: str = Field(alias=\"statusCode\")\n    reason: str = \"\"\n    create_time: int | None = Field(default=None, alias=\"createTime\")\n    expiry_time: int | None = Field(default=None, alias=\"expiryTime\")\n    retry_after: int | None = Field(default=None, alias=\"retryAfter\")\n\n    model_config = {\"populate_by_name\": True, \"extra\": \"ignore\"}\n\n    @property\n    def ok(self) -&gt; bool:\n        return self.status_code == \"300\"\n\n\ndef generate_otp(\n    transport: SmsTransport,\n    config: SmsConfig,\n    *,\n    mobile: str,\n    dlt_template_id: str | None = None,\n    code_length: int = 6,\n    expiry_seconds: int = 300,\n) -&gt; OtpGenerated:\n    payload = {\n        \"sendMethod\": \"generate\",\n        \"mobile\": mobile,\n        \"msgType\": \"text\",\n        # The literal $otp$ placeholder is substituted by the platform.\n        \"msg\": f\"{{#var#}} is your verification code. Valid for \"\n               f\"{expiry_seconds \/\/ 60} minutes.\".replace(\"{#var#}\", \"$otp$\"),\n        \"medium\": \"sms\",\n        \"codeType\": \"num\",\n        \"codeLength\": str(code_length),\n        \"codeExpiry\": str(expiry_seconds),\n        \"senderid\": config.senderid,\n    }\n    if dlt_template_id:\n        payload&#91;\"dltTemplateId\"] = dlt_template_id\n\n    response = transport.post_form(\"\/SMSApi\/otp\", payload)\n    return OtpGenerated.model_validate(response.json())\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Verify:<\/strong> same endpoint, <code>sendMethod=verify<\/code>.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def verify_otp(transport: SmsTransport, *, mobile: str, otp: str) -&gt; bool:\n    response = transport.post_form(\n        \"\/SMSApi\/otp\",\n        {\"sendMethod\": \"verify\", \"mobile\": mobile, \"otp\": otp},\n    )\n    return str(response.json().get(\"statusCode\")) == \"301\"\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two things to handle properly.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>statusCode<\/code> 300 means generated, 301 means verified.<\/strong> These are not HTTP codes. Do not compare against 200.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>retryAfter<\/code> is a millisecond epoch, not a duration.<\/strong> Convert before showing a countdown, or your users will be told to wait fifty-seven thousand years.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>from datetime import datetime, timezone\n\n\ndef seconds_until_resend(result: OtpGenerated) -&gt; int:\n    if result.retry_after is None:\n        return 0\n    target = datetime.fromtimestamp(result.retry_after \/ 1000, tz=timezone.utc)\n    delta = (target - datetime.now(timezone.utc)).total_seconds()\n    return max(0, int(delta))\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Endpoints documented at <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/generate-otp\/\">Generate OTP<\/a> and <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/verify-otp\/\">Verify OTP<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"classifying-failures-before-you-retry\">Classifying Failures Before You Retry<\/h2>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/python-classifying-failures.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"584\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/python-classifying-failures-1024x584.webp\" alt=\"Diagram about Classifying Failures\" class=\"wp-image-2619\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/python-classifying-failures-1024x584.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/python-classifying-failures-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/python-classifying-failures-768x438.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/python-classifying-failures.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Retrying everything is expensive and retrying nothing loses recoverable messages. The distinction is mechanical.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>from enum import StrEnum, auto\n\n\nclass Disposition(StrEnum):\n    SUCCESS = auto()\n    PERMANENT = auto()    # never retry, it will fail identically and bill again\n    TRANSIENT = auto()    # retry with backoff\n    AMBIGUOUS = auto()    # retry ONLY with an idempotency key\n\n\nPERMANENT_CODES = frozenset({\n    \"188\",   # template mismatch\n    # Extend from SMSApi\/info\/responsecodes rather than hardcoding by hand.\n})\n\n\ndef classify(result: SendResult | None, exc: Exception | None = None) -&gt; Disposition:\n    if exc is not None:\n        if isinstance(exc, (httpx.ConnectTimeout, httpx.ConnectError)):\n            # Never established a connection, so nothing was submitted.\n            return Disposition.TRANSIENT\n        if isinstance(exc, (httpx.ReadTimeout, httpx.WriteTimeout, httpx.RemoteProtocolError)):\n            # The request may or may not have been accepted upstream.\n            return Disposition.AMBIGUOUS\n        return Disposition.TRANSIENT\n\n    assert result is not None\n    if result.ok:\n        return Disposition.SUCCESS\n    if result.status_code in PERMANENT_CODES:\n        return Disposition.PERMANENT\n    return Disposition.TRANSIENT\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The distinction between <code>ConnectTimeout<\/code> and <code>ReadTimeout<\/code> is the part worth internalising, and it is specific to how <code>httpx<\/code> models failures.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A <strong>connect<\/strong> timeout means the TCP handshake never completed. Nothing was submitted, nothing was billed, and a plain retry is safe.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A <strong>read<\/strong> timeout means you sent the request and did not get a response. The platform may have accepted and processed it. Since credits are consumed at submission, a blind retry can bill twice and deliver twice. This is why <code>AMBIGUOUS<\/code> is a separate class rather than being folded into <code>TRANSIENT<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>requests<\/code> collapses this distinction less cleanly: <code>requests.exceptions.ConnectTimeout<\/code> is a subclass of <code>ConnectionError<\/code> and <code>ReadTimeout<\/code> is separate, but a bare <code>except requests.exceptions.Timeout<\/code> catches both. If you use <code>requests<\/code>, catch the two subclasses separately.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Build <code>PERMANENT_CODES<\/code> from the platform&#8217;s own lists rather than by hand:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def load_permanent_codes(transport: SmsTransport) -&gt; frozenset&#91;str]:\n    response = transport.post_form(\"\/SMSApi\/info\/responsecodes\", {})\n    codes = response.json()&#91;\"responsecodesList\"]\n    return frozenset(\n        str(entry&#91;\"errorcode\"])\n        for entry in codes\n        if not str(entry.get(\"httpCode\", \"\")).startswith(\"5\")\n    )\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/get-api-response-error-code-list\/\">API response code list<\/a> and the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/get-delivery-error-code-list\/\">delivery error code list<\/a> are both fetchable. Cache the result and commit a snapshot so a network failure at boot does not leave you with an empty permanent set, which would cause you to retry everything. The broader strategy is in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-retry-strategy-handling-failed-messages\/\">SMS API retry strategy guide<\/a>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Backoff with full jitter<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>import random\nimport time\n\n\ndef full_jitter_delay(attempt: int, *, base: float = 0.5, cap: float = 30.0) -&gt; float:\n    \"\"\"AWS-style full jitter. Prevents synchronised retry storms.\"\"\"\n    return random.uniform(0, min(cap, base * (2 ** attempt)))\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Exponential backoff without jitter synchronises every failed worker onto the same retry schedule, so they all hammer the recovering upstream simultaneously. Full jitter spreads them.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"concurrency-threads-asyncio-and-why-gather-is-wrong-twice\">Concurrency: Threads, Asyncio and Why gather Is Wrong Twice<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Sending SMS is I\/O bound, so the GIL is not your constraint. Both threads and asyncio work. Reach for <code>multiprocessing<\/code> here and you have misdiagnosed the problem.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The naive async version, and its two bugs<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code># WRONG in two separate ways.\nresults = await asyncio.gather(*&#91;send(m) for m in messages])\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Bug one: unbounded fan-out.<\/strong> Ten thousand messages creates ten thousand concurrent requests. You exhaust the connection pool, hit upstream rate limits, and convert a throughput problem into a failure cascade.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Bug two: first exception cancels the rest.<\/strong> By default <code>gather<\/code> propagates the first exception and you lose the results of every task that already succeeded. You now have no record of which messages were sent, so any recovery attempt risks double-sending.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The correct version<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>import asyncio\nfrom dataclasses import dataclass\n\n\n@dataclass(slots=True)\nclass Outcome:\n    mobile: str\n    result: SendResult | None\n    error: Exception | None\n\n    @property\n    def ok(self) -&gt; bool:\n        return self.result is not None and self.result.ok\n\n\nasync def send_many(\n    client: AsyncSmsClient,\n    messages: list&#91;tuple&#91;str, str]],\n    *,\n    concurrency: int = 10,\n) -&gt; list&#91;Outcome]:\n    semaphore = asyncio.Semaphore(concurrency)\n\n    async def one(mobile: str, text: str) -&gt; Outcome:\n        async with semaphore:\n            try:\n                return Outcome(mobile, await client.send(mobile=mobile, message=text), None)\n            except Exception as exc:            # noqa: BLE001 - deliberate\n                return Outcome(mobile, None, exc)\n\n    return await asyncio.gather(*(one(m, t) for m, t in messages))\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two changes. A <code>Semaphore<\/code> bounds concurrency. Catching inside the task means <code>gather<\/code> never sees an exception, so nothing is cancelled and every outcome is returned. <code>return_exceptions=True<\/code> achieves the same and returns raw exception objects instead of typed outcomes; the wrapper is more work but keeps the return type homogeneous.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Pick the concurrency number from your account&#8217;s actual limits rather than from a blog post. We are deliberately not publishing a throughput figure here, because the correct value depends on your account configuration. Start conservatively and raise it while watching your error rate.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The batch endpoint<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">For identical text going to many recipients, the send endpoint accepts multiple numbers in one call, which is dramatically cheaper in requests than one call per recipient. A general account limit of 10,000 numbers per request applies, with higher ceilings available up to 1,00,000 depending on account configuration.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two caveats that decide whether you should use it:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>It is for identical text.<\/strong> Personalised transactional messages, which is most DLT traffic, still need one call per recipient because each has different variable values.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Partial success is the hazard.<\/strong> The response carries a single <code>status<\/code> and an <code>invalidMobile<\/code> field. If a batch is largely accepted with a handful of rejections, checking only <code>status<\/code> will convince you all of them went out. Always inspect <code>rejected_numbers<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>result = client.send(mobile=\",\".join(batch), message=announcement)\n\nif result.rejected_numbers:\n    logger.warning(\n        \"batch partially rejected\",\n        extra={\"rejected_count\": len(result.rejected_numbers),\n               \"accepted_count\": len(batch) - len(result.rejected_numbers)},\n    )\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Confirm the exact delimiter and the partial-success semantics against the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/send-sms-batch\/\">Send SMS documentation<\/a> for your account before relying on batching for anything you must reconcile precisely.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"layer-four-celery-for-production-sending\">Layer Four: Celery for Production Sending<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Synchronous sending inside a web request is wrong for the same reason synchronous email is: the user waits on an upstream you do not control, and a failure becomes their problem.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>from celery import Celery\nfrom celery.exceptions import Ignore\n\napp = Celery(\"notifications\", broker=\"redis:\/\/localhost:6379\/0\")\n\napp.conf.update(\n    task_acks_late=True,\n    task_reject_on_worker_lost=True,\n    worker_prefetch_multiplier=1,\n    task_time_limit=60,\n    task_soft_time_limit=45,\n)\n\n\n@app.task(\n    bind=True,\n    max_retries=4,\n    autoretry_for=(),          # deliberately empty; we classify manually\n    acks_late=True,\n)\ndef send_sms_task(\n    self,\n    *,\n    idempotency_key: str,\n    mobile: str,\n    message: str,\n    dlt_template_id: str | None = None,\n) -&gt; dict&#91;str, object]:\n    if already_sent(idempotency_key):\n        logger.info(\"skipping duplicate\", extra={\"key\": idempotency_key})\n        raise Ignore()\n\n    client = get_client()          # process-level singleton\n\n    try:\n        result = client.send(\n            mobile=mobile,\n            message=message,\n            dlt_template_id=dlt_template_id,\n            duplicate_check=True,\n        )\n        disposition = classify(result)\n    except Exception as exc:       # noqa: BLE001\n        disposition = classify(None, exc)\n        result = None\n\n    match disposition:\n        case Disposition.SUCCESS:\n            mark_sent(idempotency_key, result.transaction_id)\n            return {\"transaction_id\": result.transaction_id}\n\n        case Disposition.PERMANENT:\n            mark_failed(idempotency_key, result.reason if result else \"unknown\")\n            logger.error(\n                \"permanent send failure, not retrying\",\n                extra={\"key\": idempotency_key,\n                       \"code\": result.status_code if result else None},\n            )\n            raise Ignore()\n\n        case Disposition.AMBIGUOUS | Disposition.TRANSIENT:\n            # AMBIGUOUS is safe to retry ONLY because duplicatecheck is on\n            # and already_sent() guards the head of this task.\n            raise self.retry(\n                countdown=full_jitter_delay(self.request.retries),\n                exc=RuntimeError(result.reason if result else \"transport failure\"),\n            )\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The configuration lines are the interesting part.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>task_acks_late=True<\/code> plus <code>task_reject_on_worker_lost=True<\/code><\/strong> means a task whose worker is killed mid-execution goes back on the queue instead of vanishing. That is the right durability choice, and it is also exactly the setting that causes duplicate sends, because a worker that crashed <strong>after<\/strong> the API accepted the message will redeliver the task. The <code>already_sent(idempotency_key)<\/code> guard at the top and <code>duplicatecheck=true<\/code> on the API call are what make it safe. Enable <code>acks_late<\/code> without both and you have built a duplicate-message generator.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>worker_prefetch_multiplier=1<\/code><\/strong> stops a single worker from reserving a large batch of tasks it then holds hostage during a slow upstream.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>autoretry_for=()<\/code><\/strong> is explicit rather than absent. Celery&#8217;s automatic retry decorator retries on any listed exception type, which cannot distinguish a permanent template mismatch from a transient network blip. Manual classification is the point.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>raise Ignore()<\/code><\/strong> on permanent failure marks the task as finished rather than failed, which keeps your failure dashboards meaningful. A template mismatch is a data problem for a human, not an incident for the on-call engineer.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The idempotency key<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Derive it from the business event, never from a random value:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import hashlib\n\n\ndef idempotency_key(event_type: str, entity_id: str, mobile: str) -&gt; str:\n    raw = f\"{event_type}:{entity_id}:{mobile}\"\n    return hashlib.sha256(raw.encode()).hexdigest()&#91;:32]\n\n\n# order_shipped:4821:919000000000  -&gt;  stable across retries AND across\n# a re-fired job, a redeployed worker, and a duplicate upstream webhook.\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A <code>uuid4()<\/code> generated at enqueue time is regenerated when the job is re-enqueued, which defeats the entire purpose. The key must be a pure function of the thing that happened.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"receiving-delivery-reports-with-fastapi\">Receiving Delivery Reports with FastAPI<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A delivery receipt tells you whether the message actually arrived. Register a webhook once:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def register_webhook(transport: SmsTransport, url: str, rate: int = 10) -&gt; dict:\n    response = transport.post_form(\n        \"\/SMSApi\/webhook\/create\",\n        {\"smswebhook\": url, \"smswebhookrate\": str(rate)},\n    )\n    return response.json()\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>smswebhookrate<\/code> is the DLR delivery rate in TPS and defaults to 10. Documented at <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/create-webhook\/\">Create Webhook<\/a>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The receiver<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>import hmac\nimport os\nfrom hashlib import sha256\n\nfrom fastapi import FastAPI, Header, HTTPException, Request, Response, status\n\napi = FastAPI()\nWEBHOOK_SECRET = os.environ&#91;\"SGC_WEBHOOK_SECRET\"].encode()\n\n\n@api.post(\"\/webhooks\/sms-dlr\")\nasync def receive_dlr(\n    request: Request,\n    x_signature: str | None = Header(default=None),\n) -&gt; Response:\n    # Read the RAW body. Do not declare a pydantic body model or Form(...)\n    # parameters: the push may be form-encoded, and a JSON parser will 422\n    # while a form model silently drops unexpected fields.\n    raw = await request.body()\n\n    if x_signature is not None:\n        expected = hmac.new(WEBHOOK_SECRET, raw, sha256).hexdigest()\n        if not hmac.compare_digest(expected, x_signature):\n            raise HTTPException(status.HTTP_401_UNAUTHORIZED, \"bad signature\")\n\n    # Persist first, parse later. An unparseable payload you still have\n    # is recoverable; one you rejected is gone forever.\n    await store_raw_dlr(raw, headers=dict(request.headers))\n    await enqueue_dlr_processing(raw)\n\n    # Acknowledge fast. Anything slow here causes upstream retries.\n    return Response(status_code=status.HTTP_200_OK)\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Four decisions, each of which is a bug if reversed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Read the raw body.<\/strong> The idiomatic FastAPI pattern is a pydantic model bound to the request body, which assumes JSON. If the push is form-encoded, that returns 422 and the platform sees a failing endpoint. <code>Form(...)<\/code> parameters handle form encoding but drop any field you did not declare, which is precisely the wrong behaviour when the schema is not publicly documented and may gain fields.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Constant-time signature comparison.<\/strong> <code>expected == x_signature<\/code> short-circuits on the first differing byte and leaks timing information. <code>hmac.compare_digest<\/code> does not.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Persist before parsing.<\/strong> If your parser has a bug, a stored raw payload can be reprocessed. A rejected one cannot.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Acknowledge immediately.<\/strong> Do the database writes and business logic in a background worker. A webhook endpoint that takes two seconds under load will be retried by the sender, and now you are processing duplicates.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Note that the outbound push payload schema is not publicly documented, which is why this receiver deliberately does not assume field names. Log a few real payloads in a staging environment, then write your parser against what you observed. Background on interpreting the values is in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/understanding-delivery-reports-dlr\/\">understanding delivery reports<\/a> entry and the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/real-time-dlr-webhooks\/\">real-time DLR webhooks<\/a> article.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"polling-delivery-reports-instead\">Polling Delivery Reports Instead<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">If you cannot expose a public endpoint, poll. The report API is cursor-paginated and safe to retry, being a read.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>from collections.abc import AsyncIterator\n\n\nasync def iter_dlr(\n    client: httpx.AsyncClient,\n    config: SmsConfig,\n    *,\n    from_date: str,\n    to_date: str,\n    page_limit: int = 500,\n) -&gt; AsyncIterator&#91;dict&#91;str, object]]:\n    \"\"\"Yield DLR records one at a time, following the cursor.\n\n    An async generator gives the consumer backpressure for free: the next\n    page is not fetched until the current page has been consumed.\n    \"\"\"\n    cursor: str | None = None\n\n    while True:\n        payload = {\n            \"method\": \"getDlr\",\n            \"userid\": config.userid,\n            \"password\": config.password,\n            \"fromdate\": from_date,\n            \"todate\": to_date,\n            \"pageLimit\": str(page_limit),\n            \"output\": \"json\",\n        }\n        if cursor:\n            payload&#91;\"startCursor\"] = cursor\n\n        response = await client.post(\"\/SMSApi\/reports\/status\", data=payload)\n        response.raise_for_status()\n\n        records = response.json().get(\"reports_dlrList\") or &#91;]\n        if not records:\n            return\n\n        for record in records:\n            yield record\n\n        cursor = records&#91;-1].get(\"cursorId\")\n        if not cursor:\n            return\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Returning an async generator rather than a list matters at scale. A month of reports for a high-volume account will not fit comfortably in memory, and the generator lets the consumer process and discard each record. The consumer also controls the pace, so a slow database write naturally throttles the fetch.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Each record carries <code>country<\/code>, <code>amount<\/code>, <code>msgType<\/code>, <code>cost<\/code>, <code>deliveryTime<\/code>, <code>length<\/code>, <code>channel<\/code>, <code>msgId<\/code>, <code>cause<\/code>, <code>mobileNo<\/code>, <code>uuId<\/code>, <code>dltTemplateId<\/code>, <code>globalErrorCode<\/code>, <code>cursorId<\/code>, <code>network<\/code>, <code>senderName<\/code>, <code>flashMsg<\/code>, <code>submitTime<\/code>, <code>text<\/code> and <code>status<\/code>. The <code>cost<\/code> and <code>length<\/code> fields let you reconcile spend, which is covered in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/bulk-sms-pricing-india-what-drives-cost\/\">pricing article<\/a>. Full reference at the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/sms-delivery-report\/\">SMS Delivery Report<\/a> page.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Use <code>Decimal<\/code> rather than <code>float<\/code> when you sum <code>cost<\/code>, for the usual reason:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>from decimal import Decimal\n\ntotal = sum((Decimal(str(r&#91;\"cost\"])) for r in records), start=Decimal(\"0\"))\n<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"the-official-python-sdk\">The Official Python SDK<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">There is a first-party SDK if you would rather not maintain the client yourself:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>pip install sgc-python-sdk\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Or from source:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>pip install git+https:\/\/github.com\/smsgatewaycenter\/sgc_python_sdk.git\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">It is published on <a href=\"https:\/\/pypi.org\/project\/sgc-python-sdk\/\">PyPI<\/a> and developed on <a href=\"https:\/\/github.com\/smsgatewaycenter\/sgc_python_sdk\">GitHub<\/a>, and the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/download-python-sdk\/\">Python SDK page<\/a> points at the repository README for configuration and usage.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">SDK or raw HTTP?<\/h3>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th><\/th><th>Official SDK<\/th><th>Hand-rolled client<\/th><\/tr><\/thead><tbody><tr><td>Time to first message<\/td><td>Minutes<\/td><td>An hour<\/td><\/tr><tr><td>Timeout and pooling control<\/td><td>Whatever the SDK chose<\/td><td>Yours<\/td><\/tr><tr><td>Response typing<\/td><td>SDK&#8217;s model<\/td><td>Your pydantic models<\/td><\/tr><tr><td>Failure classification<\/td><td>Likely generic<\/td><td>Domain-specific<\/td><\/tr><tr><td>Upgrades<\/td><td>Handled for you<\/td><td>Your problem<\/td><\/tr><tr><td>Fits an existing <code>httpx<\/code> observability stack<\/td><td>Maybe<\/td><td>Yes<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Use the SDK for scripts, internal tools, prototypes and anything where the integration is not the interesting part. Build the client shown in this article when SMS is on a revenue path and you need control over timeouts, retry semantics and instrumentation. The layered design above also means you can start with the SDK behind your layer 2 interface and swap it later without touching layers 3 and 4.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"security-and-secrets\">Security and Secrets<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Never log the payload.<\/strong> The password is in it.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import logging\n\nREDACT = {\"password\", \"apiKey\", \"otp\"}\n\n\nclass RedactingFilter(logging.Filter):\n    def filter(self, record: logging.LogRecord) -&gt; bool:\n        payload = getattr(record, \"payload\", None)\n        if isinstance(payload, dict):\n            record.payload = {\n                k: (\"***\" if k in REDACT else v) for k, v in payload.items()\n            }\n        return True\n\n\nlogging.getLogger(\"notifications\").addFilter(RedactingFilter())\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Normalise phone numbers before sending.<\/strong> Rejected numbers are free, but they are also invisible if you never check <code>invalidMobile<\/code>.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import phonenumbers\n\n\ndef to_e164_digits(raw: str, region: str = \"IN\") -&gt; str | None:\n    try:\n        parsed = phonenumbers.parse(raw, region)\n    except phonenumbers.NumberParseException:\n        return None\n    if not phonenumbers.is_valid_number(parsed):\n        return None\n    return phonenumbers.format_number(\n        parsed, phonenumbers.PhoneNumberFormat.E164\n    ).lstrip(\"+\")\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Rate-limit OTP requests per identifier.<\/strong> SMS pumping fraud works by driving OTP sends to numbers the attacker controls in expensive destinations, and you pay for every one. Cap per phone number, per account and per IP, and cap the number of verification attempts per generated code.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Do not put credentials in a URL.<\/strong> The endpoints accept GET on some paths. Credentials in a query string end up in proxy logs, browser history and referrer headers. Use POST.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"testing\">Testing<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Layer 3 needs no mocks at all, which is the payoff for keeping it pure:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def test_segment_counts_extension_characters_as_two() -&gt; None:\n    # 158 plain characters plus two braces = 162 units = 2 segments\n    message = \"a\" * 158 + \"{}\"\n    result = segment(message)\n    assert result.encoding == \"TEXT\"\n    assert result.units == 162\n    assert result.segments == 2\n\n\ndef test_rupee_sign_forces_unicode() -&gt; None:\n    assert segment(\"Pay Rs. 1499 now\").encoding == \"TEXT\"\n    assert segment(\"Pay \\u20b91499 now\").encoding == \"UNICODE\"\n\n\ndef test_render_dlt_is_immune_to_injected_placeholders() -&gt; None:\n    out = render_dlt(\"Hi {#var#}, ref {#var#}.\", &#91;\"{#var#}\", \"9\"])\n    assert out == \"Hi {#var#}, ref 9.\"\n\n\ndef test_render_dlt_rejects_arity_mismatch() -&gt; None:\n    with pytest.raises(TemplateArityError):\n        render_dlt(\"Hi {#var#}, ref {#var#}.\", &#91;\"only-one\"])\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Layer 2 gets mocked at the network boundary with <code>respx<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import httpx\nimport pytest\nimport respx\n\n\n@respx.mock\ndef test_send_parses_success_response(client: SmsClient) -&gt; None:\n    route = respx.post(\"https:\/\/unify.smsgateway.center\/SMSApi\/send\").mock(\n        return_value=httpx.Response(\n            200,\n            json={\n                \"status\": \"success\",\n                \"mobile\": \"919000000000\",\n                \"invalidMobile\": \"\",\n                \"transactionId\": \"txn-1\",\n                \"statusCode\": \"200\",\n                \"reason\": \"success\",\n            },\n        )\n    )\n\n    result = client.send(mobile=\"919000000000\", message=\"hi\")\n\n    assert result.ok\n    assert result.transaction_id == \"txn-1\"\n    # The request must be form-encoded, not JSON.\n    assert route.calls.last.request.headers&#91;\"content-type\"].startswith(\n        \"application\/x-www-form-urlencoded\"\n    )\n\n\n@respx.mock\ndef test_integer_status_code_is_coerced() -&gt; None:\n    \"\"\"WAApi\/send returns statusCode unquoted. Parsing must not care.\"\"\"\n    payload = {\"status\": \"success\", \"statusCode\": 200, \"messageId\": 123}\n    result = SendResult.model_validate(payload)\n    assert result.status_code == \"200\"\n    assert result.ok\n\n\n@respx.mock\ndef test_read_timeout_is_classified_ambiguous(client: SmsClient) -&gt; None:\n    respx.post(\"https:\/\/unify.smsgateway.center\/SMSApi\/send\").mock(\n        side_effect=httpx.ReadTimeout(\"timed out\")\n    )\n\n    with pytest.raises(httpx.ReadTimeout) as exc_info:\n        client.send(mobile=\"919000000000\", message=\"hi\")\n\n    assert classify(None, exc_info.value) is Disposition.AMBIGUOUS\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That last test is the one worth writing first. It encodes the most expensive rule in the integration in four lines, and it will fail loudly if someone later &#8220;simplifies&#8221; the classifier by folding read timeouts into the transient bucket.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you are on <code>requests<\/code>, swap <code>respx<\/code> for <code>responses<\/code>; the structure is identical.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"production-readiness-checklist\">Production Readiness Checklist<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>#<\/th><th>Item<\/th><th>Done<\/th><\/tr><\/thead><tbody><tr><td>1<\/td><td>Explicit connect and read timeouts on every call<\/td><td>\u2610<\/td><\/tr><tr><td>2<\/td><td><code>data=<\/code> used for the request body, never <code>json=<\/code><\/td><td>\u2610<\/td><\/tr><tr><td>3<\/td><td>One <code>httpx.Client<\/code> or <code>requests.Session<\/code> per process, not per call<\/td><td>\u2610<\/td><\/tr><tr><td>4<\/td><td>Transport-level automatic retries disabled on the send path<\/td><td>\u2610<\/td><\/tr><tr><td>5<\/td><td><code>statusCode<\/code> checked from the body, not the HTTP status<\/td><td>\u2610<\/td><\/tr><tr><td>6<\/td><td><code>statusCode<\/code> coerced to <code>str<\/code> so integer responses parse<\/td><td>\u2610<\/td><\/tr><tr><td>7<\/td><td><code>invalidMobile<\/code> inspected on every send, especially batches<\/td><td>\u2610<\/td><\/tr><tr><td>8<\/td><td>Segment counter unit-tested, including extension characters and emoji<\/td><td>\u2610<\/td><\/tr><tr><td>9<\/td><td>CI test asserting English templates stay GSM-7 and single-segment<\/td><td>\u2610<\/td><\/tr><tr><td>10<\/td><td>DLT variables rendered positionally, never via <code>re.sub<\/code> or chained <code>replace<\/code><\/td><td>\u2610<\/td><\/tr><tr><td>11<\/td><td><code>validateTemplate<\/code> run against every registered template in CI<\/td><td>\u2610<\/td><\/tr><tr><td>12<\/td><td>Failures classified permanent, transient and ambiguous before retrying<\/td><td>\u2610<\/td><\/tr><tr><td>13<\/td><td>Business-derived idempotency key, not <code>uuid4()<\/code><\/td><td>\u2610<\/td><\/tr><tr><td>14<\/td><td><code>duplicatecheck=true<\/code> on the send call<\/td><td>\u2610<\/td><\/tr><tr><td>15<\/td><td>Backoff uses full jitter<\/td><td>\u2610<\/td><\/tr><tr><td>16<\/td><td>Concurrency bounded by a semaphore, results collected with no cancellation<\/td><td>\u2610<\/td><\/tr><tr><td>17<\/td><td>Celery <code>acks_late<\/code> paired with an idempotency guard<\/td><td>\u2610<\/td><\/tr><tr><td>18<\/td><td>Permanent failures raise <code>Ignore()<\/code>, not a task failure<\/td><td>\u2610<\/td><\/tr><tr><td>19<\/td><td>Webhook reads the raw body and verifies with <code>hmac.compare_digest<\/code><\/td><td>\u2610<\/td><\/tr><tr><td>20<\/td><td>Webhook persists before parsing and acknowledges fast<\/td><td>\u2610<\/td><\/tr><tr><td>21<\/td><td>Secrets excluded from <code>__repr__<\/code> and from logs<\/td><td>\u2610<\/td><\/tr><tr><td>22<\/td><td>Phone numbers normalised with <code>phonenumbers<\/code> before sending<\/td><td>\u2610<\/td><\/tr><tr><td>23<\/td><td>OTP sends rate-limited per number, per account and per IP<\/td><td>\u2610<\/td><\/tr><tr><td>24<\/td><td>TLS 1.3 verified working before debugging anything else<\/td><td>\u2610<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"ten-mistakes\">Ten Mistakes<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>1. Calling <code>requests.post<\/code> without <code>timeout=<\/code>.<\/strong> It can hang forever and will eventually take down a worker pool. There is no default.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>2. Passing <code>json=<\/code> to a form-encoded endpoint.<\/strong> The request arrives with the wrong content type and no readable parameters. The error looks like an auth failure and wastes an afternoon.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>3. Creating a client per message.<\/strong> A TLS handshake per SMS. Construct once.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>4. Enabling <code>urllib3<\/code> retries on the send endpoint.<\/strong> Double-sends and double-bills whenever a response is lost in transit.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>5. Comparing <code>statusCode<\/code> to the integer <code>200<\/code>.<\/strong> Works on <code>SMSApi\/send<\/code> where it is quoted, fails on <code>WAApi\/send<\/code> where it is not. Coerce at the boundary.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>6. Using <code>re.sub<\/code> for DLT variable substitution.<\/strong> Backslashes and <code>\\g&lt;0&gt;<\/code> in user-controlled values are interpreted as replacement directives.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>7. Chaining <code>str.replace<\/code> for DLT variables.<\/strong> A value containing the placeholder literal shifts every subsequent substitution into the wrong slot.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>8. Bare <code>asyncio.gather<\/code>.<\/strong> Unbounded concurrency, and the first exception discards every successful result.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>9. <code>acks_late<\/code> without an idempotency guard.<\/strong> A worker that dies after the API accepted the message will send it again on redelivery.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>10. Declaring a pydantic body model on the DLR webhook.<\/strong> A form-encoded push returns 422, the platform records your endpoint as failing, and you lose receipts.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"frequently-asked-questions\">Frequently Asked Questions<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I send an SMS in Python?<\/strong> POST form-encoded parameters to <code>https:\/\/unify.smsgateway.center\/SMSApi\/send<\/code> using <code>httpx<\/code> or <code>requests<\/code>, with <code>userid<\/code> and <code>password<\/code> or an <code>apiKey<\/code> header, plus <code>sendMethod<\/code>, <code>mobile<\/code>, <code>msg<\/code>, <code>senderid<\/code>, <code>msgType<\/code> and <code>output=json<\/code>. Always pass an explicit timeout. Check <code>statusCode<\/code> in the JSON response body rather than the HTTP status code.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Should I use requests or httpx?<\/strong> <code>httpx<\/code> for new code. It applies a default timeout where <code>requests<\/code> applies none, it supports both sync and async with one API, and its exception hierarchy distinguishes connect from read timeouts, which matters for deciding whether a retry is safe. <code>requests<\/code> is entirely workable if it is already in your stack; just never omit <code>timeout=<\/code> and catch <code>ConnectTimeout<\/code> and <code>ReadTimeout<\/code> separately.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why does my Python SMS request fail with an authentication error when the credentials are correct?<\/strong> Almost always because you passed <code>json=payload<\/code> instead of <code>data=payload<\/code>. That sets <code>Content-Type: application\/json<\/code> on an endpoint expecting <code>application\/x-www-form-urlencoded<\/code>, so none of your parameters are read, including the credentials. The <code>output=json<\/code> parameter controls the response format, not the request format.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Is there an official Python SDK?<\/strong> Yes. <code>pip install sgc-python-sdk<\/code>, published on PyPI and developed at <code>github.com\/smsgatewaycenter\/sgc_python_sdk<\/code>. Use it for scripts and prototypes. Build your own client when you need control over timeouts, retry classification and instrumentation.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I send SMS asynchronously in Python?<\/strong> Use <code>httpx.AsyncClient<\/code> with <code>asyncio<\/code>. Bound concurrency with an <code>asyncio.Semaphore<\/code> rather than firing an unbounded <code>gather<\/code>, and either catch exceptions inside each task or pass <code>return_exceptions=True<\/code>, otherwise the first failure cancels the remaining tasks and you lose the record of what already succeeded.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I handle retries without sending duplicates?<\/strong> Classify first. Permanent failures such as a template mismatch must never be retried, since they will fail identically and consume a credit each time. Transient failures such as a connect timeout are safe to retry plainly. Read timeouts are ambiguous, because the message may have been accepted, so retry those only with a business-derived idempotency key and <code>duplicatecheck=true<\/code> on the send call.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why is my message costing more than one SMS?<\/strong> One credit covers 160 GSM-7 characters or 70 Unicode characters, dropping to 153 and 67 per segment once a message is concatenated. Any character outside the GSM-7 alphabet, such as a curly apostrophe, en dash, ellipsis or rupee sign, re-encodes the entire message to Unicode. Verify with <code>POST https:\/\/unify.smsgateway.center\/SMSApi\/info\/msg<\/code>, which returns <code>encoding<\/code>, <code>length<\/code>, <code>remaining<\/code> and <code>credit<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What is DLT and do I need it?<\/strong> DLT is India&#8217;s Distributed Ledger Technology registration regime under TRAI regulations. If you send A2P messages to Indian numbers you must register your entity, sender IDs and message templates, and pass <code>dltEntityId<\/code> and <code>dltTemplateId<\/code> on the send call. Messages that do not match a registered template are rejected.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How should I substitute variables into a DLT template in Python?<\/strong> Positionally, in a single left-to-right pass over the original template, writing into a separate output buffer. Do not use <code>re.sub<\/code>, which interprets backslash sequences in the replacement string, and do not chain <code>str.replace<\/code>, which re-scans inserted text and misplaces subsequent variables if a value contains the placeholder literal.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I receive delivery reports in FastAPI?<\/strong> Register a webhook via <code>SMSApi\/webhook\/create<\/code>, then expose a POST endpoint that reads the raw request body with <code>await request.body()<\/code> rather than binding a pydantic model, verifies any signature with <code>hmac.compare_digest<\/code>, persists the raw payload before parsing it, and returns 200 immediately while processing happens in a background worker.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can I poll for delivery reports instead of using a webhook?<\/strong> Yes. <code>POST https:\/\/unify.smsgateway.center\/SMSApi\/reports\/status<\/code> with <code>method=getDlr<\/code>, <code>fromdate<\/code>, <code>todate<\/code> and <code>pageLimit<\/code> returns a cursor-paginated <code>reports_dlrList<\/code>. Expose it as an async generator so the consumer gets natural backpressure instead of loading a month of records into memory.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How many messages can I send in one API call?<\/strong> The send endpoint accepts multiple recipients in a single request, with a general limit of 10,000 numbers and higher ceilings up to 1,00,000 available depending on account configuration. It is only appropriate for identical text; personalised transactional messages still require one call per recipient. Always inspect <code>invalidMobile<\/code>, because a batch can be partially rejected while the overall status still reads as success.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I test SMS code without sending real messages?<\/strong> Keep your domain logic free of I\/O so segment counting, template rendering and failure classification can be tested with plain unit tests. Mock the network boundary with <code>respx<\/code> for <code>httpx<\/code> or <code>responses<\/code> for <code>requests<\/code>, and assert on the outgoing request&#8217;s content type as well as its body, since a JSON content type is a real and common bug.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why am I getting SSL errors before I get any API response?<\/strong> Your Python build is probably linked against an OpenSSL version that cannot negotiate TLS 1.3. This presents as a handshake failure rather than an API error, so no amount of checking credentials will help. Resolve it first using the TLS upgrade guide, then debug the integration.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Talk to us<\/h2>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\">Building on this? Grab the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/download-python-sdk\/\">official Python SDK<\/a>, or <a href=\"https:\/\/www.smsgatewaycenter.com\/contact\/\">talk to our team<\/a> about DLT registration, sender IDs and throughput for your account.<\/p>\n<\/blockquote>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n","protected":false},"excerpt":{"rendered":"<p>Every Python SMS tutorial stops at a requests.post snippet. This one covers what happens after: why requests has no default timeout, why json= silently breaks a form-encoded API, pydantic parsing at the boundary, DLT template rendering that does not corrupt, Celery retry policy that does not double-bill, a FastAPI delivery receipt receiver, and tests.<\/p>\n","protected":false},"author":118,"featured_media":2618,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[268],"tags":[2087,2080,1277,2084,2078,2086,2085,2081,2079,2082,2083,1606],"class_list":["post-2617","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-tutorials","tag-asyncio","tag-celery","tag-dlt-compliance","tag-fastapi","tag-httpx","tag-otp-api-python","tag-pydantic","tag-python-bulk-sms","tag-python-sms-api","tag-requests-library","tag-send-sms-python","tag-sms-gateway-integration"],"_links":{"self":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/2617","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/users\/118"}],"replies":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/comments?post=2617"}],"version-history":[{"count":0,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/2617\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media\/2618"}],"wp:attachment":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media?parent=2617"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/categories?post=2617"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/tags?post=2617"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}