Integrations
Send SMS with Python
There is no Sendozi Python SDK; httpx or requests is enough. The client below serialises each body once, retries network errors, 429 and 5xx with the same Idempotency-Key, and raises an error carrying error.code. The receivers verify the timestamped signature over the raw body.
Setup
pip install httpx
export SENDOZI_API_KEY="sk_test_..." # sk_live_... in production
export SENDOZI_SENDER="YourBrand" # approved for promotional
export SENDOZI_TRANSACTIONAL_SENDER="YourBrandOTP"
export SENDOZI_WEBHOOK_SECRET="whsec_..."The client
from __future__ import annotations
import json
import os
import random
import time
from typing import Iterator
import httpx
BASE_URL = "https://api.sendozi.com/v1"
RETRYABLE = {429, 500, 502, 503, 504}
class SendoziError(RuntimeError):
"""An API refusal. Branch on .code."""
def __init__(self, status: int, error: dict, request_id: str):
super().__init__(f"{error.get('code')}: {error.get('message')}")
self.status = status
self.code = error.get("code", "unknown")
self.resolution = error.get("resolution", "")
self.request_id = request_id
class Sendozi:
def __init__(self, api_key: str | None = None, timeout: float = 30.0):
self._http = httpx.Client(
base_url=BASE_URL,
headers={"Authorization": f"Bearer {api_key or os.environ['SENDOZI_API_KEY']}"},
timeout=timeout,
)
def _call(self, method: str, path: str, body: dict | None = None, *,
idempotency_key: str | None = None, params: dict | None = None,
attempts: int = 4, timeout: float | None = None) -> dict:
# Serialised once, so every retry sends identical bytes.
content = json.dumps(body) if body is not None else None
headers = {"Content-Type": "application/json"} if content else {}
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
can_retry = method == "GET" or bool(idempotency_key)
for attempt in range(1, attempts + 1):
try:
response = self._http.request(method, path, content=content, headers=headers,
params=params, timeout=timeout or httpx.USE_CLIENT_DEFAULT)
except httpx.TransportError:
if not can_retry or attempt == attempts:
raise
time.sleep(2 ** attempt * 0.5)
continue
envelope = response.json()
if envelope.get("success"):
return envelope["data"]
error = SendoziError(response.status_code, envelope.get("error", {}), envelope.get("request_id", ""))
if not can_retry or response.status_code not in RETRYABLE or attempt == attempts:
raise error
retry_after = response.headers.get("retry-after")
time.sleep(int(retry_after) if retry_after and retry_after.isdigit()
else 2 ** attempt * 0.5 + random.random() / 4)
raise RuntimeError("unreachable")
def send_promotional(self, to: str | list[str], message: str, *, idempotency_key: str,
sender: str | None = None) -> dict:
many = isinstance(to, list)
body = {"route": "promotional", "sender": sender or os.environ["SENDOZI_SENDER"], "message": message,
**({"recipients": to} if many else {"recipient": to})}
return self._call("POST", "/sms/bulk" if many else "/sms/send", body,
idempotency_key=idempotency_key,
timeout=300.0 if many and len(to) > 10_000 else None)
def send_transactional(self, to: str, message: str, *, idempotency_key: str,
sender: str | None = None) -> dict:
"""One transactional SMS, in your own words. The key keeps a retry from sending twice."""
body = {
"route": "transactional",
"sender": sender or os.environ["SENDOZI_TRANSACTIONAL_SENDER"],
"recipient": to,
"message": message,
}
return self._call("POST", "/sms/send", body, idempotency_key=idempotency_key)
def get_message(self, message_id: str) -> dict:
return self._call("GET", f"/messages/{message_id}")
def get_batch(self, batch_id: str) -> dict:
return self._call("GET", f"/message-batches/{batch_id}")
def cancel_batch(self, batch_id: str) -> dict:
return self._call("POST", f"/message-batches/{batch_id}/cancel")
def batch_recipients(self, batch_id: str) -> Iterator[dict]:
cursor = None
while True:
params = {"limit": 500, **({"cursor": cursor} if cursor else {})}
page = self._call("GET", f"/message-batches/{batch_id}/recipients", params=params)
yield from page["data"]
if not page["has_more"]:
return
cursor = page["next_cursor"]Numbers can be written 08012345678, 8012345678, 2348012345678 or +2348012345678. Invalid and repeated numbers are dropped and counted in invalid_count and duplicates_removed rather than failing the request, unless none is valid.
Using it
import logging
import os
from django.http import JsonResponse
from .sendozi import Sendozi, SendoziError
client = Sendozi()
logger = logging.getLogger(__name__)
def request_otp(request):
attempt = create_verification_attempt(request.user)
try:
result = client.send_transactional(
to=request.user.phone,
message=f"Your Acme code is {attempt.code}. It expires in 10 minutes.",
idempotency_key=f"login-{attempt.id}",
)
except SendoziError as error:
logger.warning("otp send failed", extra={"code": error.code, "request_id": error.request_id})
return JsonResponse({"error": "We could not send the code. Try again shortly."}, status=502)
return JsonResponse({"message_id": result.get("message_id")})from celery import shared_task
from .sendozi import Sendozi
@shared_task(bind=True, max_retries=3, retry_backoff=True, retry_jitter=True)
def send_campaign(self, campaign_id: str, numbers: list[str], message: str):
# The client already retries transient failures with the same key; a
# Celery retry reuses the same key too, so it cannot queue the send twice.
batch = Sendozi().send_promotional(numbers, message, idempotency_key=f"campaign-{campaign_id}")
return {"batch_id": batch["batch_id"], "accepted": batch["accepted_count"]}Tracking a batch
client = Sendozi()
batch = client.get_batch("bat_5f45b8c07bd844c8")
print(batch["status"], batch["progress"])
for recipient in client.batch_recipients(batch["batch_id"]):
if recipient["status"] in ("failed", "skipped"):
print(recipient["recipient"], recipient["error"]["code"])Receiving webhooks
import hashlib
import hmac
import os
import time
SECRET = os.environ["SENDOZI_WEBHOOK_SECRET"].encode()
def verify_sendozi(raw: bytes, timestamp: str | None, signature: str | None) -> bool:
if not timestamp or not signature or not timestamp.isdigit():
return False
if abs(time.time() - int(timestamp)) > 300:
return False
expected = hmac.new(SECRET, timestamp.encode() + b"." + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)import json
from fastapi import FastAPI, Request, Response
from .verify import verify_sendozi
app = FastAPI()
@app.post("/webhooks/sendozi")
async def sendozi_webhook(request: Request):
raw = await request.body()
if not verify_sendozi(raw, request.headers.get("x-sendozi-timestamp"),
request.headers.get("x-sendozi-signature")):
return Response(status_code=401)
record_event.delay(json.loads(raw)) # dedupe on event["id"] in the task
return Response(status_code=200)import json
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from .verify import verify_sendozi
@csrf_exempt
@require_POST
def sendozi_webhook(request):
# request.body is the raw bytes that were signed.
if not verify_sendozi(request.body, request.headers.get("X-Sendozi-Timestamp"),
request.headers.get("X-Sendozi-Signature")):
return HttpResponse(status=401)
record_event.delay(json.loads(request.body))
return HttpResponse(status=200)Return 5xx when you could not accept an event, so Sendozi retries it. See webhooks for every event and payload.
Frequently asked questions
- Is there an official Sendozi Python library?
- No. The API is plain HTTPS with JSON, so httpx or requests is enough. The client on this page is a complete integration.
- How do I send bulk SMS from Python?
- Post a list of numbers as recipients to /v1/sms/bulk in one request, with route promotional and an Idempotency-Key. It counts as one request against the 300-a-minute limit and becomes one batch.
- Should I send SMS from a Django request handler?
- For a single OTP, yes: the user is waiting for it. Send a campaign from a background task, because queuing a large audience takes longer than a web request should.
Related reading
Getting started
Quickstart: send your first SMS
Create a sandbox key, send your first SMS, read the queued batch it returns, follow the message to delivery, and switch to production. A complete first integration.
Sending
Sending SMS
POST /v1/sms/send and /v1/sms/bulk: every request field, the response, how recipients are cleaned, limits, the delivery window, and exactly how a send is priced and charged.
Sending
Transactional SMS: OTPs, alerts and receipts
What a transactional send needs: a Transactional Sender ID, one recipient, your own message and an Idempotency-Key. No templates to register. With a working OTP example.
Reliability
Webhooks
Register a webhook endpoint, the eight events and their payloads, verifying the signature over the raw body, how retries and pausing work, and a receiver that survives repeats and reordering.
Integrations
Send SMS with Node.js
A complete Node.js integration for Sendozi: a dependency-free client, promotional and transactional sends, safe retries, batch tracking, and a verified webhook receiver for Express and Next.js.