Sending SMS looks simple until you hit carrier rules, rate limits, and messages that send but never arrive.
This guide shows you how to build reliable SMS into your app using the Mobile Text Alerts API.
You'll get a step-by-step walkthrough on how to: send your first SMS, handle retries and duplicates, track delivery, scale to thousands of recipients, schedule messages, and select the right number route. (You can also check out our no-code guide here.)
Important constraints:
Support is available to help with registration, routing, and account validation.
To generate an API key:
Never hardcode API keys. Use a .env file to store secrets and avoid leaks. The commands below use the append operator, so they are safe to run even if you already have an existing .env file.
# Adds the key to the end of the file (creates file if it doesn't exist)
echo "MTA_API_KEY=your-api-key-here" >> .env
# Ensures .env is ignored by Git
echo ".env" >> .gitignore# Safe append for PowerShell
Add-Content .env "`nMTA_API_KEY=your-api-key-here"
Add-Content .gitignore ".env":: Appends the key to the file
echo MTA_API_KEY=your-api-key-here >> .env
echo .env >> .gitignorePro Tip: If you already have an MTA_API_KEY in your file, make sure to remove the old one. Most apps only read the first entry they find.
Before writing your app logic, confirm your key works using the /verify-api-key endpoint. This confirms your headers are correct and your environment variables are loading as expected.
Run this in your terminal for an instant connection test. It’s the fastest way to confirm your API key is active.
curl -X GET "https://api.mobile-text-alerts.com/v3/auth/verify-api-key" \
-H "Authorization: Bearer YOUR_API_KEY_HERE" \
-H "Accept: application/json"Full, runnable quickstart file: In verify.js, this script tests if your code can successfully read the .env file and talk to Mobile Text Alerts.
// Run with: node --env-file=.env verify.js
const apiKey = process.env.MTA_API_KEY;
const response = await fetch(
"https://api.mobile-text-alerts.com/v3/auth/verify-api-key",
{
method: "GET",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Accept": "*/*",
},
}
);
console.log("STATUS:", response.status);
console.log("CONTENT-TYPE:", response.headers.get("Content-Type"));
const contentType = response.headers.get("Content-Type");
if (contentType && contentType.includes("application/json")) {
const data = await response.json();
console.log(data);
} else {
const text = await response.text();
console.log(text);
}Full, runnable quickstart file: In verify.py (requires pip install requests python-dotenv), run this.
import os
import requests
from dotenv import load_dotenv
load_dotenv()
api_key = os.getenv("MTA_API_KEY")
response = requests.get(
"https://api.mobile-text-alerts.com/v3/auth/verify-api-key",
headers={
"Authorization": f"Bearer {api_key}",
"Accept": "*/*",
},
)
print("STATUS:", response.status_code)
print("CONTENT-TYPE:", response.headers.get("Content-Type"))
if response.headers.get("Content-Type", "").startswith("application/json"):
print(response.json())
else:
print(response.text)If you see a 200 response with your account details, you’re authenticated.
STATUS: 200
CONTENT-TYPE: application/json; charset=utf-8
{
"message": "API Key verified",
"data": {
"name": "Account 1760025601",
"email": "youremail@email.com"
}
}| Code | Status | Cause and solution |
|---|---|---|
| 400 | Bad Request | Malformed JSON or invalid data. Double-check headers and endpoint. |
| 401 | Unauthorized | Missing or invalid API key. Check your Authorization: Bearer format. |
| 402 | Payment Required | Insufficient credits or an expired trial. |
| 403 | Forbidden | Valid key, but your account lacks permissions for this specific action, or traffic is filtered. |
| 404 | Not Found | Incorrect endpoint URL. |
| 429 | Too Many Requests | Rate limit hit. Implement a retry with exponential backoff. |
| 500 | Server Error | An issue on MTA’s end. Log the requestId and contact support. |
Now that your API key is stored in .env and verified, send a real message with POST/send. The /send endpoint requires two things:
Here's the simplest possible send: one message to one phone number. This example shows a standard OTP, but the comments explain how to stack other features like images or templates.
Full, runnable quickstart file: In test_send.js, run this code to send a test SMS to your own phone immediately.
async function sendSMS() {
const response = await fetch('https://api.mobile-text-alerts.com/v3/send', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.MTA_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
// --- RECIPIENTS ---
subscribers: ['+12345678900'], // Array of E.164 phone numbers
// --- CONTENT (Stackable) ---
message: 'Your OTP code is 123456.', // Plain text body
// image: 'https://example.com/photo.jpg', // Optional: Triggers MMS
// templateId: 123, // Optional: Uses a pre-saved template
// --- PRODUCTION CONTROLS ---
// externalId: 'otp_user_42', // Optional: For tracking in webhooks
})
});
const data = await response.json();
console.log("MTA Response:", data);
}
sendSMS();Full, runnable quickstart file: In test_send.py, use this as a standalone script to send a test SMS to your own phone. This is the quickstart version that handles all imports and environment loading.
import os
import requests
from dotenv import load_dotenv
load_dotenv()
def send_sms():
url = "https://api.mobile-text-alerts.com/v3/send"
payload = {
# --- RECIPIENTS ---
"subscribers": ["+12345678900"],
# --- CONTENT (Stackable) ---
"message": "Your OTP code is 123456.",
# "image": "https://example.com/photo.jpg",
# "templateId": 123,
# --- PRODUCTION CONTROLS ---
# "externalId": "otp_user_42",
}
response = requests.post(
url,
headers={"Authorization": f"Bearer {os.getenv('MTA_API_KEY')}"},
json=payload
)
print("MTA Response:", response.json())
if __name__ == "__main__":
send_sms(){
'data': {
'messageId': 'c71c6203-e619-4b4f-8bb9-cc06e1610627',
'totalSent': 1,
'totalFailedInternationalRecipients': 0,
'outboundIds': [271472454]
},
'message': 'Message Sent to 1 Recipient.'
}Messages are sent from your account’s default number. You can add dedicated numbers (long code, toll-free, or short code) later if you need higher throughput or two-way messaging
From your perspective, sending SMS is a single HTTP request. Behind the scenes, Mobile Text Alerts:
Every send request posted through this endpoint must include who the message is going to and what will be sent. Everything else is optional and used to control behavior in production.
Content fields are composable. You can send plain text, an image, a template, or combine them to build richer notifications.
message alongside an image to send text + media together.Note: linkId is only used with template-based content. It is not required when sending plain message text or images, even if those contain URLs.
Most SMS API users send messages directly to phone numbers from their own database, forms, and apps. The subscribers parameter accepts any phone number in E.164 format. You send to the phone numbers you already have.
Use Mobile Text Alerts’ subscriber management features (subscriberIds, groups) when you need more than simple delivery.
Common reasons include:
Both approaches still rely on dynamic phone numbers. The difference is whether you want Mobile Text Alerts to only deliver messages or also manage subscribers.
Already have a contact list? Import contacts in bulk via spreadsheet or integration instead of adding them individually through the API.
Moving from a “Hello World” script to a production environment requires thinking about reliability, scale, and observability.
The /send endpoint isn't just for text; it’s a data-rich gateway. By using optional variables like externalId, tags, and scheduledDate, you turn a simple message into a trackable business asset.
Before sending SMS in production, recipients must explicitly consent to receive texts from you. It’s required by TCPA (US), GDPR (EU), and carrier regulations worldwide.
When sending a security code, speed and traceability are everything. This pattern pulls a phone number from your user database (via SQLAlchemy for Python or Prisma for Node.js) and sends a time-sensitive code.
tags to categorize these as "security" or "OTP." This helps you filter your billing and delivery reports later to see if your login codes are hitting a higher failure rate than your marketing texts.externalId that combines the User ID and a timestamp, making it easy to map the SMS back to a specific login attempt in your logs.Snippet (adapt to your app)
from sqlalchemy.orm import Session
import requests
import os
import time
def send_login_otp(db: Session, user_id: int, otp_code: str):
# Get user from your database
user = db.query(User).filter(User.id == user_id).first()
if not user or not user.phone:
raise ValueError("User not found or has no phone number")
# Send the OTP
response = requests.post(
"https://api.mobile-text-alerts.com/v3/send",
headers={
"Authorization": f"Bearer {os.getenv('MTA_API_KEY')}",
"Content-Type": "application/json",
},
json={
"message": f"Your login code is {otp_code}. Valid for 5 minutes.",
"subscribers": [user.phone],
"externalId": f"otp_{user_id}_{int(time.time())}",
"tags": {
"type": "otp",
"userId": str(user_id),
"environment": os.getenv("ENV", "production")
}
},
timeout=30,
)
return response.json()Snippet (adapt to your app)
async function sendLoginOTP(userId, otpCode) {
// Get user from your database
const user = await prisma.user.findUnique({
where: { id: userId },
select: { phone: true }
});
if (!user || !user.phone) {
throw new Error('User not found or has no phone number');
}
// Send the OTP
const response = await fetch('https://api.mobile-text-alerts.com/v3/send', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.MTA_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
message: `Your login code is ${otpCode}. Valid for 5 minutes.`,
subscribers: [user.phone],
externalId: `otp_${userId}_${Date.now()}`,
tags: {
type: 'otp',
userId: String(userId),
environment: process.env.NODE_ENV
}
})
});
return await response.json();
}In production, you often need to send unique information to hundreds of people at once—like shipping updates or order confirmations.
Sending thousands of separate API calls is slow and inefficient. Instead, use Liquid Templates and the properties field to send one batch request where every message is personalized.
message containing variables like {{firstName}}. You then provide a properties object that maps each phone number to its specific data.Snippet (illustrative batch logic)
import requests
import os
def send_personalized_batch(orders):
# 'orders' is a list of dicts from your database
# subscribers: ["3175551111", "3175552222"]
# properties: {"3175551111": {"name": "Alice"}, "3175552222": {"name": "Bob"}}
payload = {
"subscribers": [o['phone'] for o in orders],
"message": "Hello {{firstName}}! Your order #{{orderId}} has shipped.",
"properties": {
o['phone']: {
"firstName": o['first_name'],
"orderId": o['order_id']
} for o in orders
},
"tags": {"type": "shipping_update"}
}
response = requests.post(
"https://api.mobile-text-alerts.com/v3/send",
headers={"Authorization": f"Bearer {os.getenv('MTA_API_KEY')}"},
json=payload
)
return response.json()Snippet (illustrative batch logic)
async function sendPersonalizedBatch(orders) {
const payload = {
subscribers: orders.map(o => o.phone),
message: "Hello {{firstName}}! Your order #{{orderId}} has shipped.",
properties: orders.reduce((acc, o) => {
acc[o.phone] = { firstName: o.firstName, orderId: o.orderId };
return acc;
}, {}),
tags: { type: "shipping_update" }
};
const response = await fetch('https://api.mobile-text-alerts.com/v3/send', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.MTA_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
return await response.json();
}In a production environment, the network will eventually fail. If an API request times out, you don’t know if the SMS was actually sent or if the connection dropped before it reached the server.
X-Request-Id header with a unique alphanumeric value (like a UUID). If you retry a request with the same ID, the API detects the duplicate. If it was already sent, the API returns a 409 Conflict instead of sending the message again—saving you money and preventing user spam.429 (Rate Limit) or 500 (Server Error), don't retry immediately. Wait a few seconds, then double the wait time for each subsequent attempt.Snippet (retry logic to integrate into your sender)
import time
import requests
import uuid
def send_with_retry(phone, message, max_retries=3):
# Generate a unique ID for this specific message attempt
request_id = str(uuid.uuid4())
for attempt in range(max_retries):
try:
response = requests.post(
"https://api.mobile-text-alerts.com/v3/send",
headers={
"Authorization": f"Bearer {os.getenv('MTA_API_KEY')}",
"X-Request-Id": request_id
},
json={"message": message, "subscribers": [phone]},
timeout=10
)
if response.status_code == 200:
return response.json() # Success!
if response.status_code == 409:
print("Duplicate request detected; message was already sent.")
return {"status": "already_processed"}
if response.status_code in [429, 500, 502, 503]:
# Exponential backoff: 1s, 2s, 4s...
time.sleep(2 ** attempt)
continue
except requests.exceptions.RequestException:
time.sleep(2 ** attempt)
return {"error": "Max retries reached"}Snippet (retry logic to integrate into your sender)
async function sendWithRetry(phone, message, maxRetries = 3) {
const requestId = crypto.randomUUID();
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch('https://api.mobile-text-alerts.com/v3/send', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.MTA_API_KEY}`,
'X-Request-Id': requestId,
'Content-Type': 'application/json'
},
body: JSON.stringify({ message, subscribers: [phone] })
});
if (response.ok) return await response.json();
if (response.status === 409) return { status: 'already_sent' };
// Backoff if rate limited or server error
if (response.status === 429 || response.status >= 500) {
await new Promise(res => setTimeout(res, Math.pow(2, attempt) * 1000));
continue;
}
} catch (err) {
await new Promise(res => setTimeout(res, Math.pow(2, attempt) * 1000));
}
}
}A 200 OK from the API only means we successfully received your request and put it in the queue. In the real world of telecom, a message can still fail after that point (e.g., the number is disconnected or the carrier blocked the content). To build a truly production-grade app, you need to know if the phone actually buzzed in the user's pocket.
externalId: When you send an SMS, pass your internal database ID (like order_123 or user_update_456).POST request to your server whenever the delivery status changes (e.g., delivered, failed, or rejected).Webhook handler example (reference implementation)
from fastapi import FastAPI, Request
app = FastAPI()
@app.post("/webhooks/sms-status")
async def sms_webhook(request: Request):
# Mobile Text Alerts sends status updates as JSON
data = await request.json()
external_id = data.get("externalId")
status = data.get("status") # e.g., "delivered", "failed"
delivered_at = data.get("deliveredAt")
print(f"Message {external_id} status updated to: {status}")
# PRODUCTION PATTERN: Update your database status
# await db.messages.filter(id=external_id).update(status=status, time=delivered_at)
return {"status": "success"}Webhook handler example (reference implementation)
const express = require('express');
const app = express();
app.use(express.json());
app.post('/webhooks/sms-status', (req, res) => {
const { externalId, status, deliveredAt } = req.body;
console.log(`Message ${externalId} reached status: ${status} at ${deliveredAt}`);
// PRODUCTION PATTERN: Update your database so your UI shows the correct status
// await prisma.smsLog.update({
// where: { id: externalId },
// data: { status: status, updatedAt: deliveredAt }
// });
// Always return a 200 OK to acknowledge receipt of the webhook
res.status(200).json({ received: true });
});
app.listen(3000, () => console.log('Webhook listener running on port 3000'));Mobile Text Alerts allows you to send messages through different number types (routes). The route you use determines how fast messages are sent and how much volume you can handle.
Rule of thumb: If you need fast, reliable delivery for a production app, stick with the default Toll-Free route. If you scale past 100k+ messages per day, please discuss a dedicated short code with the team.
You've sent your first message! 🎉
You now have a production-ready SMS foundation. From here, you can scale your infrastructure with these advanced patterns:
For advanced configuration, like scheduling and subscriber groups, check out the Mobile Text Alerts API documentation.
Ready to build? Get your free credits here.
Vera Agiang is a technical GTM partner and self-proclaimed math nerd. She helps developers make sense of APIs, automation tools, and AI systems.
With an engineering background and many tabs open, she builds and tests every workflow she writes about, refusing to publish anything that doesn’t work in production.
She turns complex software and messy processes into clear technical and sales assets that improve the developer experience.
Explore whether Mobile Text Alerts might be the right fit for your business.