Why “Just Retry It” Is the Wrong Default
A message fails. The obvious instinct is to send it again. For transactional SMS, that instinct is expensive to get wrong in either direction: retry too little and a customer never gets their OTP or shipping alert, retry too much and you either duplicate the message, burn through the account balance on messages that were never going to deliver, or get flagged for looking like spam traffic.
The fix is not a fixed retry count. It’s a policy that reads the actual reason a message failed and only retries the failure classes where a retry is statistically likely to succeed. That requires knowing what your gateway’s delivery report is actually telling you, and reacting differently to different codes rather than treating every non-delivery the same way.
This article assumes you’re already pulling delivery status, either through the delivery report API or through real-time DLR webhooks. If you haven’t wired that up yet, do that first. A retry strategy with no visibility into why messages fail is just guessing on a timer.

Classify the Failure Before You Retry
Every SMS failure falls into one of three layers, and each layer needs a different response.

Transport layer. The HTTP request to the Send SMS endpoint itself failed: connection timeout, DNS resolution failure, 5xx response, or a dropped TLS handshake. The message was never accepted by the gateway, so there’s no duplicate-send risk. These are safe to retry immediately with a short backoff, typically within seconds.
Carrier layer. The gateway accepted the message and returned a transaction ID, but the delivery report later comes back with a non-delivered status and a cause code such as OPERATOR_DOWN, OPERATOR_TEMP_ISSUE, SYSTEM_FAIL, or Bad Network. These are almost always transient, operator-side conditions. Retry these, but with exponential backoff and a hard cap, because retrying into a genuinely down operator every 10 seconds for an hour just wastes attempts.
Compliance layer. The message was rejected because of a scrubber match, an unregistered or mismatched DLT template ID, an invalid or unapproved sender ID, or a malformed destination number. Nothing about retrying the same request changes any of these conditions. Retrying here is pure waste, and in the case of DLT template mismatches, it can also generate confusing audit trails when you’re troubleshooting compliance issues later. If you’re still building out your DLT registration, that’s a separate and more foundational problem than retry logic.
Delivery Error Codes and What They Actually Mean for Retry Policy
SMSGatewayCenter exposes the full list of delivery status identifiers through the Delivery Error Codes API, which returns a deliverycodesList array where each entry has a peId, an identifier (the code name, e.g. SUCCESS or OTHER), a status (DELIVERED or FAILED), and a cause description. The related API Response Error Code List covers request-level errors (like invalid sender ID or malformed parameters) rather than post-submission delivery outcomes, so your retry logic needs to check both depending on where in the pipeline the failure occurred.
Rather than hardcoding every one of the roughly 45 delivery codes into your application, pull this list at startup or cache it periodically, then map each identifier your delivery reports return to one of the three retry classes above. The individual glossary write-ups on codes like IN_RETRY, SYSTEM_FAIL, OPERATOR_DOWN, OPERATOR_TEMP_ISSUE, Expired, and OTHER_SYS_FAIL are useful references for understanding what each individual code means; this section is about turning that glossary into a policy.
Note that IN_RETRY specifically means the gateway itself is already retrying the message against the operator on your behalf. If you see IN_RETRY on the delivery report, do not fire your own application-level retry on top of it. That produces the exact duplicate-send problem described in the idempotency section below. Wait for IN_RETRY to resolve to either DELIVERED or a terminal failure code, or for the message to hit Expired if it sits in the operator queue too long.
Retry Decision Matrix
| Failure signal | Layer | Retry? | Backoff strategy | Notes |
|---|---|---|---|---|
| HTTP timeout / connection reset on Send SMS call | Transport | Yes | Immediate, then short fixed delay (1-2s) | Message was never accepted; no duplicate risk |
| HTTP 5xx from gateway | Transport | Yes | Immediate, then short fixed delay | Confirm no transaction ID was returned before retrying |
OPERATOR_DOWN | Carrier | Yes | Exponential backoff, cap at 3-5 attempts | Operator infrastructure issue, not your integration |
OPERATOR_TEMP_ISSUE | Carrier | Yes | Exponential backoff, cap at 3-5 attempts | Often resolves within minutes |
SYSTEM_FAIL | Carrier | Conditional | Exponential backoff, cap at 2-3 attempts | If frequent across many messages, treat as a systemic issue and stop auto-retrying, escalate to support |
| Bad Network / network fluctuation | Carrier | Yes | Exponential backoff | Transient by definition |
IN_RETRY | Carrier (gateway-managed) | No | N/A | Gateway is already retrying; do not stack your own retry on top |
Expired | Carrier | No (resend as new message, don’t retry same attempt) | N/A | Message sat too long in queue; if still relevant, send fresh with new transaction |
| Invalid or unregistered sender ID | Compliance | No | N/A | Fix the sender ID configuration first |
| DLT template ID mismatch or rejection | Compliance | No | N/A | Correct the DLT template mapping, retrying does nothing |
| Malformed or invalid destination number | Compliance | No | N/A | Validate input before sending, not after failure |
Building an Idempotent Retry Queue
The single most common bug in home-grown SMS retry systems is duplicate sends: a message is accepted by the gateway, the delivery report is delayed or the webhook fires late, the application times out waiting and re-sends the same message, and now the customer gets the OTP twice.
The fix is to generate your own idempotency key per logical send attempt, separate from the gateway’s transactionId, and to use the gateway’s built-in duplicatecheck parameter on the Send SMS endpoint as a second line of defense, not the only line of defense. Your application should track state per message: queued, sent-awaiting-dlr, delivered, failed-retryable, failed-terminal. Only messages in failed-retryable state, past their backoff window, and under their retry cap should re-enter the send queue.

A minimal state machine:
- Message enters queue with a unique
idempotencyKey. - On send, record
transactionIdreturned by the gateway and move tosent-awaiting-dlr. - Poll the DLR by UUID endpoint or consume the delivery webhook.
- On
DELIVERED, mark complete. - On a retryable failure code, check retry count against the cap. If under cap, schedule next attempt using the backoff table above and increment retry count. If over cap, move to
failed-terminaland alert. - On a non-retryable failure code, move directly to
failed-terminaland alert, skipping the backoff logic entirely.
This is the same queuing discipline described at a systems level in Understanding SMS Gateway Architecture, which covers dead-letter queues for messages that exhaust all retry attempts. That article is the right reference if you’re designing the infrastructure this retry logic runs on top of; this article is about the decision logic inside it.
Code: Retry Logic in PHP, Node.js, Python, Java, and cURL
The examples below use the confirmed Send SMS endpoint (POST https://unify.smsgateway.center/SMSApi/send) and the DLR-by-UUID endpoint (https://unify.smsgateway.center/SMSApi/reports/status with method=getDlr and a uuId parameter), both from the developer API documentation. Each sample implements the retryable/non-retryable split from the decision matrix above and caps retries at 3 attempts with exponential backoff.
cURL (single send with duplicatecheck)
curl -X POST "https://unify.smsgateway.center/SMSApi/send" \
-H "content-type: application/x-www-form-urlencoded" \
--data-urlencode "userid=YourUsername" \
--data-urlencode "password=YourPassword" \
--data-urlencode "sendMethod=quick" \
--data-urlencode "mobile=919999999999" \
--data-urlencode "msg=Your OTP is 482913" \
--data-urlencode "senderid=YOURID" \
--data-urlencode "msgType=text" \
--data-urlencode "duplicatecheck=true" \
--data-urlencode "output=json"
PHP
<?php
$retryableCodes = ["OPERATOR_DOWN", "OPERATOR_TEMP_ISSUE", "SYSTEM_FAIL", "BADNETWORK"];
$maxRetries = 3;
function sendSms($mobile, $msg, $senderid) {
$curl = curl_init();
curl_setopt_array($curl, array(
CURLOPT_URL => "https://unify.smsgateway.center/SMSApi/send",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => http_build_query([
"userid" => "YourUsername",
"password" => "YourPassword",
"sendMethod" => "quick",
"mobile" => $mobile,
"msg" => $msg,
"senderid" => $senderid,
"msgType" => "text",
"duplicatecheck" => "true",
"output" => "json"
]),
CURLOPT_HTTPHEADER => ["content-type: application/x-www-form-urlencoded"]
));
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
// Transport-layer failure: safe to retry immediately
return ["transport_error" => true];
}
return json_decode($response, true);
}
function checkDlr($uuId) {
$curl = curl_init();
curl_setopt_array($curl, array(
CURLOPT_URL => "https://unify.smsgateway.center/SMSApi/reports/status",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => http_build_query([
"userid" => "YourUsername",
"password" => "YourPassword",
"method" => "getDlr",
"uuId" => $uuId,
"output" => "json"
]),
CURLOPT_HTTPHEADER => ["content-type: application/x-www-form-urlencoded"]
));
$response = curl_exec($curl);
curl_close($curl);
return json_decode($response, true);
}
function attemptSendWithRetry($mobile, $msg, $senderid) {
global $retryableCodes, $maxRetries;
$attempt = 0;
$backoff = 2; // seconds
while ($attempt < $maxRetries) {
$attempt++;
$result = sendSms($mobile, $msg, $senderid);
if (isset($result["transport_error"])) {
sleep($backoff);
$backoff *= 2;
continue;
}
$uuId = $result["uuId"] ?? $result["transactionId"] ?? null;
if (!$uuId) {
// Compliance-layer or request-level rejection, don't retry
return ["status" => "failed-terminal", "reason" => $result];
}
sleep(5); // allow time for DLR to populate
$dlr = checkDlr($uuId);
$status = $dlr["reports_dlrList"][0]["status"] ?? null;
$cause = $dlr["reports_dlrList"][0]["cause"] ?? null;
if ($status === "DELIVERED") {
return ["status" => "delivered"];
}
if (in_array($cause, $retryableCodes)) {
sleep($backoff);
$backoff *= 2;
continue;
}
return ["status" => "failed-terminal", "reason" => $cause];
}
return ["status" => "failed-max-retries"];
}
Node.js
const axios = require('axios');
const RETRYABLE_CODES = ['OPERATOR_DOWN', 'OPERATOR_TEMP_ISSUE', 'SYSTEM_FAIL', 'BADNETWORK'];
const MAX_RETRIES = 3;
async function sendSms(mobile, msg, senderid) {
try {
const response = await axios.post('https://unify.smsgateway.center/SMSApi/send', new URLSearchParams({
userid: 'YourUsername',
password: 'YourPassword',
sendMethod: 'quick',
mobile,
msg,
senderid,
msgType: 'text',
duplicatecheck: 'true',
output: 'json'
}), { timeout: 30000 });
return response.data;
} catch (err) {
return { transportError: true };
}
}
async function checkDlr(uuId) {
const response = await axios.post('https://unify.smsgateway.center/SMSApi/reports/status', new URLSearchParams({
userid: 'YourUsername',
password: 'YourPassword',
method: 'getDlr',
uuId,
output: 'json'
}));
return response.data;
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function attemptSendWithRetry(mobile, msg, senderid) {
let backoff = 2000;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
const result = await sendSms(mobile, msg, senderid);
if (result.transportError) {
await sleep(backoff);
backoff *= 2;
continue;
}
const uuId = result.uuId || result.transactionId;
if (!uuId) {
return { status: 'failed-terminal', reason: result };
}
await sleep(5000);
const dlr = await checkDlr(uuId);
const record = dlr.reports_dlrList && dlr.reports_dlrList[0];
if (record && record.status === 'DELIVERED') {
return { status: 'delivered' };
}
if (record && RETRYABLE_CODES.includes(record.cause)) {
await sleep(backoff);
backoff *= 2;
continue;
}
return { status: 'failed-terminal', reason: record ? record.cause : 'unknown' };
}
return { status: 'failed-max-retries' };
}
module.exports = { attemptSendWithRetry };
Python
import time
import requests
RETRYABLE_CODES = {"OPERATOR_DOWN", "OPERATOR_TEMP_ISSUE", "SYSTEM_FAIL", "BADNETWORK"}
MAX_RETRIES = 3
def send_sms(mobile, msg, senderid):
try:
response = requests.post(
"https://unify.smsgateway.center/SMSApi/send",
data={
"userid": "YourUsername",
"password": "YourPassword",
"sendMethod": "quick",
"mobile": mobile,
"msg": msg,
"senderid": senderid,
"msgType": "text",
"duplicatecheck": "true",
"output": "json"
},
timeout=30
)
return response.json()
except requests.exceptions.RequestException:
return {"transport_error": True}
def check_dlr(uu_id):
response = requests.post(
"https://unify.smsgateway.center/SMSApi/reports/status",
data={
"userid": "YourUsername",
"password": "YourPassword",
"method": "getDlr",
"uuId": uu_id,
"output": "json"
}
)
return response.json()
def attempt_send_with_retry(mobile, msg, senderid):
backoff = 2
for attempt in range(1, MAX_RETRIES + 1):
result = send_sms(mobile, msg, senderid)
if result.get("transport_error"):
time.sleep(backoff)
backoff *= 2
continue
uu_id = result.get("uuId") or result.get("transactionId")
if not uu_id:
return {"status": "failed-terminal", "reason": result}
time.sleep(5)
dlr = check_dlr(uu_id)
records = dlr.get("reports_dlrList", [])
record = records[0] if records else None
if record and record.get("status") == "DELIVERED":
return {"status": "delivered"}
if record and record.get("cause") in RETRYABLE_CODES:
time.sleep(backoff)
backoff *= 2
continue
return {"status": "failed-terminal", "reason": record.get("cause") if record else "unknown"}
return {"status": "failed-max-retries"}
Java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Set;
public class SmsRetryClient {
private static final Set<String> RETRYABLE_CODES = Set.of(
"OPERATOR_DOWN", "OPERATOR_TEMP_ISSUE", "SYSTEM_FAIL", "BADNETWORK"
);
private static final int MAX_RETRIES = 3;
private final HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(30))
.build();
public String sendSms(String mobile, String msg, String senderid) throws Exception {
String form = "userid=YourUsername&password=YourPassword&sendMethod=quick"
+ "&mobile=" + mobile + "&msg=" + msg + "&senderid=" + senderid
+ "&msgType=text&duplicatecheck=true&output=json";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://unify.smsgateway.center/SMSApi/send"))
.header("content-type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(form))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
return response.body();
}
public String checkDlr(String uuId) throws Exception {
String form = "userid=YourUsername&password=YourPassword&method=getDlr"
+ "&uuId=" + uuId + "&output=json";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://unify.smsgateway.center/SMSApi/reports/status"))
.header("content-type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(form))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
return response.body();
}
// Wrap sendSms/checkDlr calls in your own retry loop using RETRYABLE_CODES
// and exponential backoff, mirroring the PHP/Node.js/Python state machine above.
// Parse the JSON response with your JSON library of choice (Jackson, Gson) to
// extract "cause" and "status" from reports_dlrList before deciding whether to retry.
}
Exponential Backoff Without Overloading the Gateway
Exponential backoff means each retry waits longer than the last: 2 seconds, then 4, then 8, and so on, rather than hammering the endpoint at a fixed interval. This matters for two reasons that are specific to SMS delivery rather than generic API retries. First, operator-side outages (OPERATOR_DOWN) typically resolve on the order of minutes, not seconds, so retrying every 2 seconds for the first minute accomplishes nothing except adding load. Second, if you’re sending at volume over SMPP, aggressive uncoordinated retries across many failed messages at once can itself contribute to throughput problems, which is the same throughput-management concern covered in SMPP Rate Limits and Throughput Tuning. Add jitter, a small random offset to each backoff interval, so that a batch of messages that all failed at the same moment don’t all retry at exactly the same moment again.
Cap total retries per message at a small number, typically 3, and set a maximum total retry window (for example, don’t keep retrying an OTP message past 5 minutes; the OTP is worthless to the user by then regardless of whether it eventually delivers). For non-time-sensitive transactional messages, a longer window with more attempts may make sense, but the cap should still exist. Uncapped retry loops are the single most common cause of runaway SMS costs in poorly built integrations, a concern also discussed for HTTP APIs generally in Twilio’s retry logic guidance for SMS 2FA and in general software engineering literature on the circuit breaker pattern, which is worth adopting alongside backoff: if a large percentage of recent sends are failing with the same carrier-layer code, stop sending new messages to that route entirely for a cooldown period rather than retrying each one individually.
When Retrying Makes Things Worse
Retrying is not free, and treating it as a default safety net causes specific, predictable problems:
Retrying a compliance-layer failure changes nothing about the message’s validity and just consumes API calls and, depending on your account’s cost model, potentially credits, while delaying the moment someone notices the actual configuration problem (wrong sender ID, missing DLT template mapping, scrubber match). Fix the input, don’t retry the output.
Retrying without checking for IN_RETRY status first creates duplicate sends, because the gateway is already handling the retry on your behalf. This is the single most avoidable mistake in home-grown retry logic.
Retrying past a message’s useful lifetime for the use case wastes effort on a message the recipient no longer needs. A payment confirmation retried 20 minutes later after already failing has usually been superseded by the customer contacting support directly.
Retrying at a fixed short interval across a large batch of failed messages at once can look, from the receiving operator’s side, like abnormal traffic, and is worth avoiding for account health reasons independent of the retry’s success probability.
Monitoring Your Retry Pipeline
Track, at minimum, retry rate by failure code (not just an aggregate retry rate), average number of attempts before final resolution, and the ratio of messages that resolve to DELIVERED after a retry versus those that exhaust all attempts. A sudden spike in SYSTEM_FAIL or OPERATOR_DOWN retry volume across many messages at once is usually a sign of a genuine upstream operator issue rather than something your retry logic can fix by trying harder, and it’s the trigger to open a support ticket rather than widen your retry cap.
FAQs
Should I retry every failed SMS the same number of times? No. Retry count and backoff should depend on the specific failure code. Transport-layer failures can retry quickly. Carrier-layer failures need exponential backoff with a cap. Compliance-layer failures shouldn’t be retried at all.
How do I avoid sending the same message twice on retry? Use the gateway’s duplicatecheck parameter as a safeguard, but the primary defense is application-level state tracking: don’t fire a new send attempt until you’ve confirmed the previous attempt’s delivery report shows a genuinely retryable terminal failure, not a pending or IN_RETRY state.
What’s the difference between IN_RETRY and my own retry logic? IN_RETRY means the gateway itself is still attempting delivery to the operator on the original send. Firing your own application-level retry while a message is in this state creates a duplicate send once both attempts eventually succeed.
Is there a standard number of retries I should use? Three attempts with exponential backoff is a reasonable default for carrier-layer failures on time-sensitive transactional messages. There’s no universal number; it should be tuned against your own delivery success data by failure code.
Should retries use a different sender ID or route? That’s a fallback strategy, not a retry strategy, and it introduces its own compliance considerations (a message resent under a different registered sender ID may need its own DLT template mapping in India). Treat it as a separate, deliberate decision rather than an automatic retry behavior.
Does the DLT registration status affect retry behavior? Yes. If a message fails because of a DLT template mismatch or an unregistered entity, retrying the exact same request will fail identically every time, because the cause is a configuration issue, not a transient one. If you haven’t completed the DLT registration for your route, that’s the underlying issue to resolve first.
Production-Ready Delivery Starts Here
Get your delivery report and error code mapping right before scaling retry logic across your message volume. Explore the Delivery Report API and Delivery Error Codes reference, or sign up to test retry logic against a live sandbox account.