This tutorial covers sending SMS from Python using nothing but the standard requests library against a plain REST API — no heavyweight SDK required. By the end you'll have a working Flask endpoint you can adapt into any real project.
You'll need:
requests libraryInstall requests (and python-dotenv for environment variable loading):
pip install requests python-dotenv
Create a .env file in your project root — and make sure it's in .gitignore before you commit anything:
SENDEXA_API_KEY=your_api_key_here
SENDEXA_API_SECRET=your_api_secret_here
Sendexa's API uses HTTP Basic authentication, which requests supports natively via the auth parameter — no manual header encoding needed:
# send_test.py
import os
import requests
from dotenv import load_dotenv
load_dotenv()
API_KEY = os.environ["SENDEXA_API_KEY"]
API_SECRET = os.environ["SENDEXA_API_SECRET"]
def send_sms(to: str, message: str, sender: str = "SENDEXA") -> dict:
response = requests.post(
"https://api.sendexa.co/v1/sms/send",
auth=(API_KEY, API_SECRET),
json={"to": to, "from": sender, "message": message},
timeout=10,
)
response.raise_for_status()
return response.json()
if __name__ == "__main__":
result = send_sms("+233501234567", "Hello from Python! 🚀")
print(result)
Run it:
python send_test.py
A successful call returns a JSON response confirming the message was accepted:
{
"success": true,
"code": "SMS_SENT",
"message": "Message accepted for delivery.",
"data": {
"messageId": "MSG-8f21c4a9",
"delivery": {
"status": "PENDING",
"route": { "type": "domestic", "destination": "Ghana" }
},
"billing": { "units": 1, "totalCost": 0.01, "currency": "USD" }
}
}
Wrap the send logic in an actual API route your frontend or other services can call:
# app.py
import os
import re
import requests
from flask import Flask, request, jsonify
from dotenv import load_dotenv
load_dotenv()
app = Flask(__name__)
API_KEY = os.environ["SENDEXA_API_KEY"]
API_SECRET = os.environ["SENDEXA_API_SECRET"]
E164_PATTERN = re.compile(r"^\+[1-9]\d{7,14}$")
def is_valid_e164(phone_number: str) -> bool:
return bool(E164_PATTERN.match(phone_number))
@app.route("/api/send-sms", methods=["POST"])
def send_sms_endpoint():
data = request.get_json(silent=True) or {}
to = data.get("to")
message = data.get("message")
if not to or not message:
return jsonify({"error": "'to' and 'message' are required"}), 400
if not is_valid_e164(to):
return jsonify({"error": "Phone number must be in E.164 format, e.g. +233501234567"}), 400
try:
response = requests.post(
"https://api.sendexa.co/v1/sms/send",
auth=(API_KEY, API_SECRET),
json={"to": to, "from": "SENDEXA", "message": message},
timeout=10,
)
response.raise_for_status()
result = response.json()
return jsonify({"success": True, "messageId": result["data"]["messageId"]}), 200
except requests.exceptions.HTTPError as error:
status = error.response.status_code if error.response is not None else 502
app.logger.error("SMS send failed: %s", error)
return jsonify({"success": False, "error": "Failed to send SMS"}), status
except requests.exceptions.RequestException as error:
app.logger.error("SMS send failed: %s", error)
return jsonify({"success": False, "error": "Failed to send SMS"}), 502
if __name__ == "__main__":
app.run(port=3000)
Different failure modes deserve different handling rather than one generic except:
try:
response = requests.post(
"https://api.sendexa.co/v1/sms/send",
auth=(API_KEY, API_SECRET),
json={"to": to, "from": "SENDEXA", "message": message},
timeout=10,
)
response.raise_for_status()
except requests.exceptions.HTTPError as error:
status = error.response.status_code
if status == 401:
# Invalid or expired API credentials
pass
elif status == 400:
# Malformed request — bad phone number, empty message, etc.
pass
elif status == 429:
# Rate limited — back off and retry
pass
except requests.exceptions.Timeout:
# Network timeout — safe to retry with backoff
pass
except requests.exceptions.ConnectionError:
# Upstream unreachable
pass
As with any external API call, never surface the raw exception text to end users — log it server-side and return a clean, generic error message instead.
The initial response confirms the message was accepted, not that it arrived — delivery is asynchronous. Set up a webhook endpoint to receive the final status:
@app.route("/webhooks/sms-status", methods=["POST"])
def sms_status_webhook():
payload = request.get_json(silent=True) or {}
message_id = payload.get("messageId")
status = payload.get("status") # DELIVERED, FAILED, EXPIRED
app.logger.info("Message %s status: %s", message_id, status)
# TODO: update your own database record for this messageId
return "OK", 200
Register this endpoint's URL in your Sendexa dashboard so delivery events are pushed to it in real time instead of requiring you to poll.
That's a complete Python integration: credentials loaded safely from the environment, Basic auth handled natively by requests, input validation, a working Flask route, and webhook-based delivery tracking. The same send_sms() helper drops straight into OTP verification, order notifications, or any other transactional flow — swap the message text and destination number and you're done.
For the Node.js equivalent of this tutorial, see How to Send SMS with Node.js.
