SMS API Retry Strategy: How to Handle Failed Message Delivery

Retry logic for SMS should be driven by the specific failure reason on the delivery report, not by a blanket "try again three times" rule. Transport-layer failures (timeouts, connection resets) are almost always safe to retry immediately. Carrier-layer failures reported through the delivery report, such as OPERATOR_DOWN or SYSTEM_FAIL, should be retried with exponential backoff and a retry cap. Compliance-layer failures, such as a rejected DLT template or an invalid sender ID, should never be retried automatically because retrying does not fix the underlying cause and just burns credits. Every retry attempt needs an idempotency check so a slow delivery report doesn't cause the same message to be sent twice.

Featured image for SMS API Retry Strategy: How to Handle Failed Message Delivery

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.

Flowchart illustration showing SMS delivery outcomes branching into delivered, retry, and permanent failure paths

Classify the Failure Before You Retry

Every SMS failure falls into one of three layers, and each layer needs a different response.

Illustration about Classify the Failure Before You Retry

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 signalLayerRetry?Backoff strategyNotes
HTTP timeout / connection reset on Send SMS callTransportYesImmediate, then short fixed delay (1-2s)Message was never accepted; no duplicate risk
HTTP 5xx from gatewayTransportYesImmediate, then short fixed delayConfirm no transaction ID was returned before retrying
OPERATOR_DOWNCarrierYesExponential backoff, cap at 3-5 attemptsOperator infrastructure issue, not your integration
OPERATOR_TEMP_ISSUECarrierYesExponential backoff, cap at 3-5 attemptsOften resolves within minutes
SYSTEM_FAILCarrierConditionalExponential backoff, cap at 2-3 attemptsIf frequent across many messages, treat as a systemic issue and stop auto-retrying, escalate to support
Bad Network / network fluctuationCarrierYesExponential backoffTransient by definition
IN_RETRYCarrier (gateway-managed)NoN/AGateway is already retrying; do not stack your own retry on top
ExpiredCarrierNo (resend as new message, don’t retry same attempt)N/AMessage sat too long in queue; if still relevant, send fresh with new transaction
Invalid or unregistered sender IDComplianceNoN/AFix the sender ID configuration first
DLT template ID mismatch or rejectionComplianceNoN/ACorrect the DLT template mapping, retrying does nothing
Malformed or invalid destination numberComplianceNoN/AValidate 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.

Illustration about Building an Idempotent Retry Queue

A minimal state machine:

  1. Message enters queue with a unique idempotencyKey.
  2. On send, record transactionId returned by the gateway and move to sent-awaiting-dlr.
  3. Poll the DLR by UUID endpoint or consume the delivery webhook.
  4. On DELIVERED, mark complete.
  5. 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-terminal and alert.
  6. On a non-retryable failure code, move directly to failed-terminal and 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.

Save this interesting page on your favorite Social Media

Blog Author logo

SMS Gateway Center Desk

SMS Gateway Center is one of the largest and leading SMS Provider in India. It is run by a large professional team to cater small companies to large corporate companies. SMS Gateway Center is associated with the best operators in India covering the entire states in India. SMS Gateway Center has been serving through its SMS Resellers in more than 20 states in India. To become our SMS Reseller, kindly contact us

Looking for the best business communication solutions, get in touch!